diff --git a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs index e18d1e4e2..1ce98e9b5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Characters/CharacterInfo.cs @@ -543,7 +543,7 @@ namespace Barotrauma Identifier factionId = inc.ReadIdentifier(); float minReputationToHire = 0.0f; - if (factionId != default) + if (!factionId.IsEmpty) { minReputationToHire = inc.ReadSingle(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxConnection.cs b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxConnection.cs index ea89c9f57..dfe8278e5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxConnection.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxConnection.cs @@ -73,13 +73,16 @@ namespace Barotrauma if (isMouseOver) { - var glowSprite = GUIStyle.UIGlowCircular.Value.Sprite; - float glowScale = 40f / glowSprite.size.X; - if (isScrewed) + var glowSprite = GUIStyle.UIGlowCircular.Value?.Sprite; + if (glowSprite is not null) { - glowScale *= 1.2f; + float glowScale = 40f / glowSprite.size.X; + if (isScrewed) + { + glowScale *= 1.2f; + } + glowSprite.Draw(spriteBatch, position, GUIStyle.Yellow, glowSprite.size / 2, scale: glowScale); } - glowSprite.Draw(spriteBatch, position, GUIStyle.Yellow, glowSprite.size / 2, scale: glowScale); } tooltip = Option.None; diff --git a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs index a195b3276..ee7a45761 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/CircuitBox/CircuitBoxUI.cs @@ -24,7 +24,7 @@ namespace Barotrauma private GUIFrame? selectedWireFrame; private GUIListBox? componentList; private GUITextBlock? inventoryIndicatorText; - private readonly Sprite cursorSprite = GUIStyle.CursorSprite[CursorState.Default]; + private readonly Sprite? cursorSprite = GUIStyle.CursorSprite[CursorState.Default]; private Option selection = Option.None; private string searchTerm = string.Empty; diff --git a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs index 2b766254c..5f45b7d56 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/ContentManagement/Transition/LegacySteamUgcTransition.cs @@ -198,7 +198,7 @@ namespace Barotrauma.Transition DebugConsole.ThrowError("There was an error transferring mods", t2.Exception.GetInnermost()); } ContentPackageManager.LocalPackages.Refresh(); - if (t2.TryGetResult(out string[] modsToEnable)) + if (t2.TryGetResult(out string[]? modsToEnable)) { var newRegular = ContentPackageManager.EnabledPackages.Regular.ToList(); newRegular.AddRange(ContentPackageManager.LocalPackages.Regular diff --git a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs index 8df615ee8..fa1990bcb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/DebugConsole.cs @@ -9,10 +9,12 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text; +using System.Threading.Tasks; using System.Xml.Linq; using static Barotrauma.FabricationRecipe; @@ -35,9 +37,7 @@ namespace Barotrauma if (!allowCheats && !CheatsEnabled && IsCheat) { NewMessage("You need to enable cheats using the command \"enablecheats\" before you can use the command \"" + Names[0] + "\".", Color.Red); -#if USE_STEAM - NewMessage("Enabling cheats will disable Steam achievements during this play session.", Color.Red); -#endif + NewMessage("Enabling cheats will disable achievements during this play session.", Color.Red); return; } @@ -66,8 +66,12 @@ namespace Barotrauma private static GUIFrame frame; private static GUIListBox listBox; private static GUITextBox textBox; +#if DEBUG + private const int maxLength = 100000; +#else private const int maxLength = 1000; - +#endif + public static GUITextBox TextBox => textBox; private static readonly ChatManager chatManager = new ChatManager(true, 64); @@ -157,7 +161,7 @@ namespace Barotrauma activeQuestionText?.SetAsLastChild(); - if (PlayerInput.KeyHit(Keys.F3)) + if (PlayerInput.KeyHit(Keys.F3) && !PlayerInput.KeyDown(Keys.LeftControl) && !PlayerInput.KeyDown(Keys.RightControl)) { Toggle(); } @@ -247,6 +251,9 @@ namespace Barotrauma case "unbindkey": case "wikiimage_character": case "wikiimage_sub": + case "eosStat": + case "eosUnlink": + case "eosLoginEpicViaSteam": return true; default: return client.HasConsoleCommandPermission(command); @@ -389,6 +396,87 @@ namespace Barotrauma private static void InitProjectSpecific() { + commands.Add(new Command("eosStat", "Query and display all logged in EOS users. Normally this is at most two users, but in a developer environment it could be more.", args => + { + if (!EosInterface.Core.IsInitialized) + { + NewMessage("EOS not initialized"); + return; + } + + var loggedInUsers = EosInterface.IdQueries.GetLoggedInPuids(); + if (!loggedInUsers.Any()) + { + NewMessage("EOS user not logged in"); + return; + } + + NewMessage("Logged in EOS users:"); + foreach (var puid in loggedInUsers) + { + TaskPool.Add( + $"eosStat -> {puid}", + EosInterface.IdQueries.GetSelfExternalAccountIds(puid), + t => + { + if (!t.TryGetResult(out Result, EosInterface.IdQueries.GetSelfExternalIdError> result)) { return; } + NewMessage($" - {puid}"); + + if (result.TryUnwrapSuccess(out var ids)) + { + foreach (var id in ids) + { + NewMessage($" - {id}"); + if (id is EpicAccountId eaid) + { + async Task gameOwnershipTokenTest() + { + var tokenOption = await EosInterface.Ownership.GetGameOwnershipToken(eaid); + var verified = await tokenOption.Bind(t => t.Verify()); + NewMessage($"Ownership token verify result: {verified}"); + } + _ = gameOwnershipTokenTest(); // Fire and forget! + EosInterface.Login.TestEosSessionTimeoutRecovery(puid); + } + } + } + else + { + NewMessage($" - Failed to get external IDs linked to {puid}: {result}"); + } + }); + } + })); + AssignRelayToServer("eosStat", false); + + commands.Add(new Command("eosUnlink", "Unlink the primary logged in external account ID from its corresponding EOS Product User ID and close the game. This is meant to be used to test the EOS consent flow.", args => + { + var userId = EosInterface.IdQueries.GetLoggedInPuids().FirstOrDefault(); + NewMessage($"Unlinking external account from PUID {userId}"); + GameSettings.SetCurrentConfig(GameSettings.CurrentConfig with { CrossplayChoice = Eos.EosSteamPrimaryLogin.CrossplayChoice.Unknown }); + GameSettings.SaveCurrentConfig(); + TaskPool.Add("unlinkTask", EosInterface.Login.UnlinkExternalAccount(userId), _ => + { + GameMain.Instance.Exit(); + }); + })); + AssignRelayToServer("eosUnlink", false); + + commands.Add(new Command("eosLoginEpicViaSteam", "Log into an Epic account via a link to the currently logged in Steam account", + args => + { + TaskPool.Add("eosLoginEpicViaSteam", + Eos.EosEpicSecondaryLogin.LoginToLinkedEpicAccount(), + TaskPool.IgnoredCallback); + })); + AssignRelayToServer("eosLoginEpicViaSteam", false); + + commands.Add(new Command("resetgameanalyticsconsent", "Reset whether you've given your consent for the game to send statistics to GameAnalytics. After executing the command, the game should ask for your consent again on relaunch.", args => + { + GameAnalyticsManager.ResetConsent(); + })); + AssignRelayToServer("resetgameanalyticsconsent", false); + commands.Add(new Command("copyitemnames", "", (string[] args) => { StringBuilder sb = new StringBuilder(); @@ -422,18 +510,16 @@ namespace Barotrauma } })); - commands.Add(new Command("enablecheats", "enablecheats: Enables cheat commands and disables Steam achievements during this play session.", (string[] args) => + commands.Add(new Command("enablecheats", "enablecheats: Enables cheat commands and disables achievements during this play session.", (string[] args) => { CheatsEnabled = true; - SteamAchievementManager.CheatsEnabled = true; + AchievementManager.CheatsEnabled = true; if (GameMain.GameSession?.Campaign is CampaignMode campaign) { campaign.CheatsEnabled = true; } NewMessage("Enabled cheat commands.", Color.Red); -#if USE_STEAM - NewMessage("Steam achievements have been disabled during this play session.", Color.Red); -#endif + NewMessage("Achievements have been disabled during this play session.", Color.Red); })); AssignRelayToServer("enablecheats", true); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Eos/EosAccount.cs b/Barotrauma/BarotraumaClient/ClientSource/Eos/EosAccount.cs new file mode 100644 index 000000000..6197ad2fd --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Eos/EosAccount.cs @@ -0,0 +1,203 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Networking; +using Barotrauma.Steam; +using Microsoft.Xna.Framework; + +namespace Barotrauma.Eos; + +internal static class EosAccount +{ + /// + /// The user can have several account IDs if they've linked their Steam account to an Epic Games account + /// + public static ImmutableHashSet SelfAccountIds { get; private set; } = ImmutableHashSet.Empty; + + private static readonly Queue postLoginActions = new(); + private static bool isLoggedIn; + + public static void RefreshSelfAccountIds(Action? onRefreshComplete = null) + { + SelfAccountIds = ImmutableHashSet.Empty; + var selfPuids = EosInterface.IdQueries.GetLoggedInPuids(); + + if (selfPuids.Length == 0) + { + onRefreshComplete?.Invoke(); + return; + } + + var collectedIds = new Option>[selfPuids.Length]; + + Action taskDoneHandler(int index) + { + void countDoneTask(Task t) + { + try + { + if (!t.TryGetResult(out Result, EosInterface.IdQueries.GetSelfExternalIdError>? result)) { return; } + if (!result.TryUnwrapSuccess(out var ids)) { return; } + collectedIds[index] = Option.Some(ids); + } + finally + { + // If we failed to get IDs from this query, fill in the relevant slot in the collectedIds array + // to indicate that the task is done anyway + collectedIds[index] = Option.Some(collectedIds[index].Fallback(ImmutableArray.Empty)); + + // If all of the tasks are done, merge all of the collected IDs into the hashset + if (collectedIds.All(o => o.IsSome())) + { + SelfAccountIds = collectedIds.NotNone().SelectMany(a => a).ToImmutableHashSet(); + onRefreshComplete?.Invoke(); + } + } + } + + return countDoneTask; + } + + for (int i = 0; i < selfPuids.Length; i++) + { + TaskPool.Add($"SelfPlayerRowWithExternalAccountIds{i}", + EosInterface.IdQueries.GetSelfExternalAccountIds(selfPuids[i]), + taskDoneHandler(i)); + } + } + + #region Message box stuff + private static GUIMessageBox? messageBox; + + public static void ReplaceMessageBox(GUIMessageBox? newMessageBox) + { + messageBox?.Close(); + messageBox = newMessageBox; + } + + public static void CloseMessageBox() + => ReplaceMessageBox(null); + + public static GUIMessageBox CreateLoadingMessageBox((Func CanCancel, Action Cancel)? actions = null) + { + var relativeSize = messageBox?.InnerFrame.RectTransform.RelativeSize ?? (0.35f, 0.3f); + var newMessageBox = new GUIMessageBox( + headerText: LocalizedString.EmptyString, + text: LocalizedString.EmptyString, + relativeSize: relativeSize, + buttons: actions != null ? new[] { TextManager.Get("Cancel") } : Array.Empty()); + + if (actions != null) + { + newMessageBox.Buttons[0].Visible = false; + newMessageBox.Buttons[0].OnClicked = (_, _) => + { + actions.Value.Cancel.Invoke(); + return false; + }; + new GUICustomComponent(new RectTransform(Vector2.Zero, newMessageBox.InnerFrame.RectTransform), onUpdate: + (_, _) => + { + bool canCancel = actions.Value.CanCancel.Invoke(); + newMessageBox.Buttons[0].Visible |= canCancel; + newMessageBox.Buttons[0].Enabled = canCancel; + }); + } + + new GUICustomComponent( + new RectTransform(Vector2.One * 0.25f, newMessageBox.InnerFrame.RectTransform, Anchor.Center, scaleBasis: ScaleBasis.Smallest), + onDraw: static (sb, component) => + { + GUIStyle.GenericThrobber.Draw( + sb, + spriteIndex: (int)(Timing.TotalTime * 20f) % GUIStyle.GenericThrobber.FrameCount, + pos: component.Rect.Center.ToVector2(), + color: Color.White, + origin: GUIStyle.GenericThrobber.FrameSize.ToVector2() * 0.5f, + rotate: 0f, + scale: component.Rect.Size.ToVector2() / GUIStyle.GenericThrobber.FrameSize.ToVector2()); + }); + ReplaceMessageBox(newMessageBox); + return newMessageBox; + } + + public static Action RetryAction(LocalizedString intro, LocalizedString reason, Action retryAction, Action cancelAction) + { + return () => GameMain.ExecuteAfterContentFinishedLoading(() => AskRetry(intro, reason, retryAction, cancelAction)); + } + + private static void AskRetry(LocalizedString intro, LocalizedString failureReason, Action retryAction, Action cancelAction) + { + var options = new[] + { + TextManager.Get("Retry"), + TextManager.Get("Cancel") + }; + var askHowToProceed = TextManager.Get("AskHowToProceed"); + + GUIMessageBox msgBox = new GUIMessageBox( + headerText: TextManager.Get("EosIntroHeader"), + text: intro + "\n\n" + failureReason + "\n\n" + askHowToProceed, + options, + relativeSize: (0.4f, 0.4f)); + + msgBox.Buttons[0].OnClicked = delegate + { + retryAction(); + CloseMessageBox(); + return false; + }; + + msgBox.Buttons[1].OnClicked = delegate + { + cancelAction(); + CloseMessageBox(); + return false; + }; + + ReplaceMessageBox(msgBox); + } + #endregion + + public static void LoginPlatformSpecific() + { + if (GameMain.Instance.EgsExchangeCode.TryUnwrap(out var exchangeCode)) + { + LoginEpic(exchangeCode); + } + else if (SteamManager.IsInitialized) + { + LoginSteam(); + } + } + + private static void LoginSteam() + => EosSteamPrimaryLogin.Start(); + + private static void LoginEpic(string exchangeCode) + => EosEpicPrimaryLogin.Start(exchangeCode); + + public static void OnLoginSuccess() + { + isLoggedIn = true; + while (postLoginActions.TryDequeue(out var action)) + { + action(); + } + } + + public static void ExecuteAfterLogin(Action action) + { + if (isLoggedIn) + { + action(); + return; + } + postLoginActions.Enqueue(action); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Epic/EosEpicPrimaryLogin.cs b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Epic/EosEpicPrimaryLogin.cs new file mode 100644 index 000000000..501fc0c47 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Epic/EosEpicPrimaryLogin.cs @@ -0,0 +1,70 @@ +#nullable enable +using System; +using System.Threading.Tasks; + +namespace Barotrauma.Eos; + +/// +/// Handles a player that owns a copy of Barotrauma on Epic Games Store (therefore they +/// will use their Epic Account ID as their primary identity) logging into EOS. +/// +static class EosEpicPrimaryLogin +{ + public static void Start(string exchangeCode) + { + TaskPool.Add("Eos.Core.LoginEpic", Initialize(exchangeCode), t => + { + if (!t.TryGetResult(out Action? action)) { return; } + action(); + }); + } + + private static void Success() + { + Eos.EosAccount.CloseMessageBox(); + Eos.EosAccount.RefreshSelfAccountIds(); + EosAccount.OnLoginSuccess(); + } + + private static async Task Initialize(string exchangeCode) + { + void retry() => Start(exchangeCode); + static void cancel() => EosInterface.Core.CleanupAndQuit(); + + var failedToInitializeIntro = TextManager.Get("EosFailedToInitialize"); + + var loginResult = await EosInterface.Login.LoginEpicExchangeCode(exchangeCode); + if (!loginResult.TryUnwrapSuccess(out var either)) + { + LocalizedString localizedError = $"Login failed with unknown error code."; + if (loginResult.TryUnwrapFailure(out EosInterface.Login.LoginError errorCode)) + { + localizedError = TextManager + .Get($"EosInterface.Core.InitError.{errorCode}") + .Fallback($"Failed to initialize Epic Online Services (error code {errorCode})"); + } + return EosAccount.RetryAction(failedToInitializeIntro, localizedError, retry, cancel); + } + + if (either.TryGet(out EosInterface.EosConnectContinuanceToken eosContinuanceToken)) + { + var createProductAccountResult = await EosInterface.Login.CreateProductAccount(eosContinuanceToken); + if (!createProductAccountResult.TryUnwrapSuccess(out var puid)) + { + return EosAccount.RetryAction( + failedToInitializeIntro, + $"Failed to create product user account: {(createProductAccountResult.TryUnwrapFailure(out var failure) ? failure : "unknown")}", + retry, cancel); + } + DebugConsole.NewMessage($"Logged into EOS for the first time with Epic as primary external account ID: {puid}"); + return Success; + } + else if (either.TryGet(out EosInterface.ProductUserId puid)) + { + DebugConsole.NewMessage($"Logged into EOS with Epic as primary external account ID: {puid}"); + return Success; + } + + throw new UnreachableCodeException(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosEpicSecondaryLogin.cs b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosEpicSecondaryLogin.cs new file mode 100644 index 000000000..15803f492 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosEpicSecondaryLogin.cs @@ -0,0 +1,201 @@ +#nullable enable +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Extensions; + +namespace Barotrauma.Eos; + +/// +/// Handles a player that owns a copy of Barotrauma on Steam, +/// and wishes to link their Steam account to an Epic account +/// to interact with friends on Epic Games' account system. +/// +static class EosEpicSecondaryLogin +{ + public enum ProbeResult + { + NoAccount, + LinkedExternalAccountsButNoPuid, + LoggedIn + } + + public static async Task ProbeLinkedEpicAccount() + { + var loginResult = await EosInterface.Login.LoginEpicWithLinkedSteamAccount(EosInterface.Login.LoginEpicFlags.FailWithoutOpeningBrowser); + if (!loginResult.TryUnwrapSuccess(out var success)) { return ProbeResult.NoAccount; } + + if (success.TryGet(out EosInterface.ProductUserId _)) + { + // Make Steam account the primary external account just in case + await EosInterface.Login.LoginSteam(); + + return ProbeResult.LoggedIn; + } + if (success.TryGet(out EosInterface.EosConnectContinuanceToken? _)) + { + return ProbeResult.LinkedExternalAccountsButNoPuid; + } + + return ProbeResult.NoAccount; + } + + public enum LoginErrorDesc + { + NoPrimaryPuid, + + FailedToLogInViaLinkedEpicAccount, + + FailedToForceSteamAsPrimaryExternalAccountId, + SteamIsNoLongerLinkedToPuid, + SteamPuidMismatchedPreviousPrimaryPuid, + + FailedToLinkSteamAccountToEpicAccount, + FailedToCreatePuidForEpicAccount, + + UnhandledErrorCondition + } + + public readonly record struct LoginError( + LoginErrorDesc ErrorDesc, + Option LoginEosConnectError = default, + Option LinkExternalToEpicError = default, + Option CreatePuidError = default) + { + public override string ToString() + { + string error = $"LoginError ({ErrorDesc}"; + if (LoginEosConnectError.TryUnwrap(out var connectError)) + { + error += $", {connectError}"; + } + if (LinkExternalToEpicError.TryUnwrap(out var externalToEpicError)) + { + error += $", {externalToEpicError}"; + } + if (CreatePuidError.TryUnwrap(out var createPuidError)) + { + error += $", {createPuidError}"; + } + return error + ")"; + } + } + + public static async Task> LoginToLinkedEpicAccount() + { + var primaryPuidOption = EosInterface.IdQueries.GetLoggedInPuids().FirstOrNone(); + if (!primaryPuidOption.TryUnwrap(out var primaryPuid)) { return Result.Failure(new LoginError(LoginErrorDesc.NoPrimaryPuid)); } + + // No matter what happens, refresh account IDs when returning + using var janitor = Janitor.Start(); + janitor.AddAction(static () => EosAccount.RefreshSelfAccountIds()); + + async Task> makeSteamPrimaryExternalAccount() + { + // By logging into EOS connect via Steam, we make sure that it's + // treated as the primary external account, which means that it's + // prioritized over the Epic account ID in other EOS functions + var loginSteamResult = await EosInterface.Login.LoginSteam(); + if (!loginSteamResult.TryUnwrapSuccess(out var loginSteamSuccess)) + { + return Result.Failure(new LoginError(LoginErrorDesc.FailedToForceSteamAsPrimaryExternalAccountId)); + } + + if (!loginSteamSuccess.TryGet(out EosInterface.ProductUserId primaryPuidAgain)) + { + return Result.Failure(new LoginError(LoginErrorDesc.SteamIsNoLongerLinkedToPuid)); + } + + if (primaryPuid != primaryPuidAgain) + { + return Result.Failure(new LoginError(LoginErrorDesc.SteamPuidMismatchedPreviousPrimaryPuid)); + } + + return Result.Success(Unit.Value); + } + + const int MaxLoginPasses = 5; + for (int loginPass = 0; loginPass < MaxLoginPasses; loginPass++) + { + // Try to log into EOS via Epic via Steam several times, + // only stop once we get a PUID that's linked only to the Epic account + + if (EosInterface.IdQueries.GetLoggedInEpicIds() is { Length: > 0 } loggedInEpicIds) + { + // Log out of any Epic accounts to reduce chances of ending up in an inconsistent state + await Task.WhenAll(loggedInEpicIds.Select(EosInterface.Login.LogoutEpicAccount)); + } + + var loginResult = await EosInterface.Login.LoginEpicWithLinkedSteamAccount( + loginPass == 0 + ? EosInterface.Login.LoginEpicFlags.None + : EosInterface.Login.LoginEpicFlags.FailWithoutOpeningBrowser); + + if (loginResult.TryUnwrapFailure(out var loginEpicFailure)) + { + return Result.Failure(new LoginError(LoginErrorDesc.FailedToLogInViaLinkedEpicAccount, LoginEosConnectError: Option.Some(loginEpicFailure))); + } + if (!loginResult.TryUnwrapSuccess(out var loginEpicSuccess)) + { + throw new UnreachableCodeException(); + } + + if (loginEpicSuccess.TryGet(out EosInterface.ProductUserId secondPuid)) + { + if (primaryPuid == secondPuid) + { + // Somehow we've got two external accounts linked + // to the same PUID, let's yank them apart + + // Given that the latest ID used to log into this account was + // the Epic account, this call to UnlinkExternalAccount will + // keep the SteamID linked to this PUID and only unlink the + // Epic account, which is what we want. + await EosInterface.Login.UnlinkExternalAccount(secondPuid); + + // Once that's done, log back into EOS Connect with + // the SteamID as the primary ID + if ((await makeSteamPrimaryExternalAccount()).TryUnwrapFailure(out var loginSteamError)) + { + return Result.Failure(loginSteamError); + } + } + else + { + // We already have a PUID for this Epic account. We're done here! + + return Result.Success(Unit.Value); + } + } + else if (loginEpicSuccess.TryGet(out EosInterface.EgsAuthContinuanceToken? egsAuthContinuanceToken)) + { + // We got an EGS Auth continuance token, which means that the player + // has provided an Epic account to link the current Steam account to. + var linkExternalToEpicResult = await EosInterface.Login.LinkExternalAccountToEpicAccount(egsAuthContinuanceToken); + if (linkExternalToEpicResult.TryUnwrapFailure(out var linkExternalToEpicError)) + { + return Result.Failure(new LoginError(LoginErrorDesc.FailedToLinkSteamAccountToEpicAccount, LinkExternalToEpicError: Option.Some(linkExternalToEpicError))); + } + } + else if (loginEpicSuccess.TryGet(out EosInterface.EosConnectContinuanceToken? eosConnectContinuanceToken)) + { + // We got an EOS Connect continuance token, we need + // a new Product User ID for the given Epic account. + + var createPuidResult = await EosInterface.Login.CreateProductAccount(eosConnectContinuanceToken); + if (createPuidResult.TryUnwrapFailure(out var createPuidError)) + { + return Result.Failure(new LoginError(LoginErrorDesc.FailedToCreatePuidForEpicAccount, CreatePuidError: Option.Some(createPuidError))); + } + + // PUID has been created! We're done here! + return Result.Success(Unit.Value); + } + else + { + throw new UnreachableCodeException(); + } + } + + return Result.Failure(new LoginError(LoginErrorDesc.UnhandledErrorCondition)); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosSteamPrimaryLogin.cs b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosSteamPrimaryLogin.cs new file mode 100644 index 000000000..f65a38331 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Eos/PrimaryLogin/Steam/EosSteamPrimaryLogin.cs @@ -0,0 +1,321 @@ +#nullable enable +using Barotrauma.Steam; +using Microsoft.Xna.Framework; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Barotrauma.Eos; + +/// +/// Handles a player that owns a copy of Barotrauma on Steam (therefore they +/// will use their SteamID as their primary identity) logging into EOS. +/// +public static class EosSteamPrimaryLogin +{ + public static bool IsNewEosPlayer = false; + + public enum CrossplayChoice + { + Unknown, + Enabled, + Disabled + } + + public static CrossplayChoice EnableCrossplay + { + get => GameSettings.CurrentConfig.CrossplayChoice; + set + { + GameSettings.SetCurrentConfig(GameSettings.CurrentConfig with { CrossplayChoice = value }); + GameAnalyticsManager.AddDesignEvent("Crossplay:" + value); + GameSettings.SaveCurrentConfig(); + } + } + + public static void Start() + { + TaskPool.Add( + "EosSteamPrimaryLogin", + Initialize(), + OnTaskComplete); + } + + private static void OnTaskComplete(Task t) + { + if (t.Exception?.GetInnermost() is { } exception) + { + DebugConsole.ThrowError($"{nameof(EosSteamPrimaryLogin)}.{nameof(Initialize)} failed with exception {exception.Message} {exception.StackTrace.CleanupStackTrace()}"); + } + if (!t.TryGetResult(out Action? action)) { return; } + action(); + } + + private static void Success() + { + Eos.EosAccount.CloseMessageBox(); + Eos.EosAccount.RefreshSelfAccountIds(); + EosAccount.OnLoginSuccess(); + } + + private static async Task Initialize() + { + static void retry() => Start(); + static void cancel() => EosInterface.Core.CleanupAndQuit(); + var failedToInitializeIntro = TextManager.Get("EosFailedToInitialize"); + + if (EnableCrossplay is CrossplayChoice.Unknown) + { + // Don't even try to initialize EOS until we get the user's consent + return SteamAccountHasNoLinkedPuid(); + } + if (EnableCrossplay is CrossplayChoice.Disabled) + { + // Crossplay is disabled, return immediately + return Success; + } + + if (!SteamManager.IsInitialized) + { + return EosAccount.RetryAction(failedToInitializeIntro, "Steamworks is not initialized", retry, cancel); + } + + Result initResult = Result.Failure(EosInterface.Core.InitError.UnhandledErrorCondition); + CrossThread.RequestExecutionOnMainThread(() => initResult = EosInterface.Core.Init(EosInterface.ApplicationCredentials.Client, enableOverlay: false)); + if (initResult.TryUnwrapFailure(out var initError)) + { + return EosAccount.RetryAction(failedToInitializeIntro, GetErrorMessage(initError), retry, cancel); + } + + var steamPuidResult = await EosInterface.Login.LoginSteam(); + + if (!steamPuidResult.TryUnwrapSuccess(out var steamPuidOrContToken)) + { + return EosAccount.RetryAction(failedToInitializeIntro, $"Failed to log into EOS with Steam account: {steamPuidResult}", retry, cancel); + } + + if (steamPuidOrContToken.TryGet(out EosInterface.ProductUserId puid)) + { + return await SteamAccountHasLinkedPuid(puid); + } + else if (steamPuidOrContToken.TryGet(out EosInterface.EosConnectContinuanceToken _)) + { + return SteamAccountHasNoLinkedPuid(); + } + throw new UnreachableCodeException(); + } + + private static async Task SteamAccountHasLinkedPuid(EosInterface.ProductUserId _) + { + await EosEpicSecondaryLogin.ProbeLinkedEpicAccount(); + return Success; + } + + private static Action SteamAccountHasNoLinkedPuid() + { + return () => GameMain.ExecuteAfterContentFinishedLoading(AskPlayerToEnableCrossplay); + } + + private static void AskPlayerToEnableCrossplay() + { + LocalizedString[] options = + { + TextManager.Get("EnableCrossplay"), + TextManager.Get("DisableCrossplay") + }; + + var introText = "\n" + LocalizedString.Join( + "\n\n", + Enumerable.Range(0, 3).Select(static i => TextManager.Get($"EosIntro{i}"))) + "\n"; + + GUIMessageBox msgBox = new GUIMessageBox( + headerText: TextManager.Get("EosIntroHeader"), + text: introText, + Array.Empty(), + relativeSize: (0.8f, 0.5f)); + msgBox.Content.ChildAnchor = Anchor.TopCenter; + msgBox.Content.Stretch = true; + msgBox.InnerFrame.RectTransform.ScaleBasis = ScaleBasis.Smallest; + + int? selectedRadioButton = null; + var radioButtonLayout = new GUILayoutGroup(new RectTransform(Vector2.One, msgBox.Content.RectTransform)) { Stretch = true }; + var radioButtonGroup = new GUIRadioButtonGroup(); + for (int i = 0; i < options.Length; i++) + { + var radioButton = new GUITickBox( + new RectTransform(Vector2.One, radioButtonLayout.RectTransform), + label: options[i], + style: "GUIRadioButton"); + radioButtonGroup.AddRadioButton( + key: i, + radioButton: radioButton); + radioButton.RectTransform.MinSize = Point.Zero; + radioButton.RectTransform.MaxSize = new Point(int.MaxValue); + radioButton.RectTransform.ScaleBasis = ScaleBasis.Normal; + radioButton.RectTransform.RelativeSize = Vector2.One; + } + + //spacing + new GUIFrame(new RectTransform(new Point(0), radioButtonLayout.RectTransform) { MinSize = new Point(0, GUI.IntScale(30)) }, style: null); + + var submitButton = new GUIButton(new RectTransform(Vector2.One, radioButtonLayout.RectTransform), + TextManager.Get("Submit").Fallback("Submit")) { Enabled = false }; + + radioButtonGroup.OnSelect = (rbg, val) => + { + selectedRadioButton = val; + submitButton.Enabled = true; + }; + msgBox.ForceLayoutRecalculation(); + var maxOptionWidth = options.Select(o => GUIStyle.Font.MeasureString(o).X).Max(); + int extraWidth = (int)(GUIStyle.Font.LineHeight * 4f); + radioButtonLayout.RectTransform.IsFixedSize = true; + radioButtonLayout.RectTransform.NonScaledSize = new Point((int)maxOptionWidth + extraWidth, (int)(GUIStyle.Font.LineHeight * options.Length * 1.5f) + submitButton.Rect.Height * 2); + msgBox.ForceLayoutRecalculation(); + + static void textSizeFixHack(GUITextBlock textBlock, int width) + { + textBlock.RectTransform.IsFixedSize = true; + textBlock.RectTransform.MinSize = Point.Zero; + textBlock.RectTransform.MaxSize = new Point(int.MaxValue); + textBlock.RectTransform.NonScaledSize = new Point(width, 0); + textBlock.CalculateHeightFromText(); + } + textSizeFixHack(msgBox.Header, (int)(msgBox.InnerFrame.Rect.Width * 0.9f)); + textSizeFixHack(msgBox.Text, (int)(msgBox.InnerFrame.Rect.Width * 0.9f)); + msgBox.ForceLayoutRecalculation(); + + msgBox.InnerFrame.RectTransform.IsFixedSize = true; + msgBox.InnerFrame.RectTransform.NonScaledSize = new Point( + msgBox.InnerFrame.Rect.Width, + (int)((msgBox.Content.Children.Select(c => c.Rect.Height + GUI.IntScale(5)).Sum() + GUIStyle.Font.LineHeight) / 0.9f)); + + submitButton.OnClicked = delegate + { + switch (selectedRadioButton) + { + case 0: + PlayerWantsToEnableCrossplay(); + return false; + case 1: + PlayerWantsToDisableCrossplay(); + return false; + default: + throw new UnreachableCodeException(); + } + }; + + Eos.EosAccount.ReplaceMessageBox(msgBox); + } + + private static void PlayerWantsToEnableCrossplay() + { + Eos.EosAccount.CreateLoadingMessageBox(); + TaskPool.Add( + nameof(EnableCrossplayAndCreatePuidWithOneToken), + EnableCrossplayAndCreatePuidWithOneToken(), + OnTaskComplete); + } + + private static void PlayerWantsToDisableCrossplay() + { + EosInterface.Core.CleanupAndQuit(); + var action = DisableCrossplay(); + action(); + } + + private static async Task EnableCrossplayAndCreatePuidWithOneToken() + { + void retry() => PlayerWantsToEnableCrossplay(); + static void cancel() => EosInterface.Core.CleanupAndQuit(); + var failedToCreatePuidIntro = TextManager.Get("FailedToCreatePuid"); + + EnableCrossplay = CrossplayChoice.Enabled; + + if (!SteamManager.IsInitialized) + { + return EosAccount.RetryAction(failedToCreatePuidIntro, "Steamworks is not initialized", retry, cancel); + } + + Result initResult = Result.Failure(EosInterface.Core.InitError.UnhandledErrorCondition); + CrossThread.RequestExecutionOnMainThread(() => initResult = EosInterface.Core.Init(EosInterface.ApplicationCredentials.Client, enableOverlay: false)); + if (initResult.TryUnwrapFailure(out var initError)) + { + return EosAccount.RetryAction(failedToCreatePuidIntro, GetErrorMessage(initError), retry, cancel); + } + + EosInterface.EosConnectContinuanceToken steamEosContinuanceToken; + var steamLoginResult = await EosInterface.Login.LoginSteam(); + if (steamLoginResult.TryUnwrapSuccess(out var either)) + { + if (either.TryGet(out EosInterface.EosConnectContinuanceToken newSteamCt)) + { + steamEosContinuanceToken = newSteamCt; + } + else + { + await EosEpicSecondaryLogin.ProbeLinkedEpicAccount(); + SocialOverlay.Instance?.DisplayBindHintToPlayer(); + return Success; + } + } + else + { + return EosAccount.RetryAction(failedToCreatePuidIntro, $"Failed to refresh continuance token: {steamLoginResult}", retry, cancel); + } + + var newPuidResult = await EosInterface.Login.CreateProductAccount(steamEosContinuanceToken); + if (newPuidResult.IsFailure) + { + return EosAccount.RetryAction(failedToCreatePuidIntro, $"Failed to create PUID: {newPuidResult}", retry, cancel); + } + + IsNewEosPlayer = true; + SocialOverlay.Instance?.DisplayBindHintToPlayer(); + return Success; + } + + private static LocalizedString GetErrorMessage(EosInterface.Core.InitError errorCode) + { + return TextManager.Get($"EosInterface.Core.InitError.{errorCode}").Fallback($"Failed to initialize Epic Online Services (error code {errorCode})"); + } + + private static Action DisableCrossplay() + { + EnableCrossplay = CrossplayChoice.Disabled; + + return Success; + } + + public static void HandleCrossplayChoiceChange(CrossplayChoice newChoice) + { + if (StoreIntegration.CurrentStore != StoreIntegration.Store.Steam) { return; } + if (GameSettings.CurrentConfig.CrossplayChoice == newChoice) { return; } + + switch (newChoice) + { + case CrossplayChoice.Disabled: + EosInterface.Core.CleanupAndQuit(); + break; + case CrossplayChoice.Enabled: + if (EosInterface.Core.CurrentStatus == EosInterface.Core.Status.ShutDown) + { + var msgBox = new GUIMessageBox(TextManager.Get("EosAllowCrossplay"), + TextManager.Get("RestartRequiredGeneric"), new[] { TextManager.Get("ok") }) + { + DrawOnTop = true + }; + msgBox.Buttons[0].OnClicked = (_, _) => + { + msgBox.Close(); + return true; + }; + } + else + { + PlayerWantsToEnableCrossplay(); + } + break; + } + } +} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs b/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs index 4afb04ff2..5a41e279f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Fonts/ScalableFont.cs @@ -65,25 +65,11 @@ namespace Barotrauma private int texDims; private uint baseChar; - private readonly struct GlyphData - { - public readonly int TexIndex; - public readonly Vector2 DrawOffset; - public readonly float Advance; - public readonly Rectangle TexCoords; - - public GlyphData( - int texIndex = default, - Vector2 drawOffset = default, - float advance = default, - Rectangle texCoords = default) - { - TexIndex = texIndex; - DrawOffset = drawOffset; - Advance = advance; - TexCoords = texCoords; - } - } + public readonly record struct GlyphData( + int TexIndex = default, + Vector2 DrawOffset = default, + float Advance = default, + Rectangle TexCoords = default); public static TextManager.SpeciallyHandledCharCategory ExtractShccFromXElement(XElement element) => TextManager.SpeciallyHandledCharCategories @@ -94,7 +80,7 @@ namespace Barotrauma // For backwards compatibility, we assume that Cyrillic is supported by default TextManager.SpeciallyHandledCharCategory.Cyrillic => true, - + _ => throw new NotImplementedException($"nameof{category} not implemented.") })) .Aggregate(TextManager.SpeciallyHandledCharCategory.None, (current, category) => current | category); @@ -209,8 +195,8 @@ namespace Barotrauma if (glyphIndex == 0) { texCoords.Add(j, new GlyphData( - advance: 0, - texIndex: -1)); + Advance: 0, + TexIndex: -1)); continue; } face.LoadGlyph(glyphIndex, LoadFlags.Default, LoadTarget.Normal); @@ -218,8 +204,8 @@ namespace Barotrauma { //glyph is empty, but char might still apply advance GlyphData blankData = new GlyphData( - advance: Math.Max((float)face.Glyph.Metrics.HorizontalAdvance, 0f), - texIndex: -1); //indicates no texture because the glyph is empty + Advance: Math.Max((float)face.Glyph.Metrics.HorizontalAdvance, 0f), + TexIndex: -1); //indicates no texture because the glyph is empty texCoords.Add(j, blankData); continue; @@ -262,10 +248,10 @@ namespace Barotrauma } GlyphData newData = new GlyphData( - advance: (float)face.Glyph.Metrics.HorizontalAdvance, - texIndex: texIndex, - texCoords: new Rectangle((int)currentCoords.X, (int)currentCoords.Y, glyphWidth, glyphHeight), - drawOffset: new Vector2(face.Glyph.BitmapLeft, baseHeight * 14 / 10 - face.Glyph.BitmapTop) + Advance: (float)face.Glyph.Metrics.HorizontalAdvance, + TexIndex: texIndex, + TexCoords: new Rectangle((int)currentCoords.X, (int)currentCoords.Y, glyphWidth, glyphHeight), + DrawOffset: new Vector2(face.Glyph.BitmapLeft, baseHeight * 14 / 10 - face.Glyph.BitmapTop) ); texCoords.Add(j, newData); @@ -354,8 +340,8 @@ namespace Barotrauma if (glyphIndex == 0) { texCoords.Add(character, new GlyphData( - advance: 0, - texIndex: -1)); + Advance: 0, + TexIndex: -1)); continue; } @@ -365,8 +351,8 @@ namespace Barotrauma { //glyph is empty, but char might still apply advance GlyphData blankData = new GlyphData( - advance: Math.Max((float)face.Glyph.Metrics.HorizontalAdvance, 0f), - texIndex: -1); //indicates no texture because the glyph is empty + Advance: Math.Max((float)face.Glyph.Metrics.HorizontalAdvance, 0f), + TexIndex: -1); //indicates no texture because the glyph is empty texCoords.Add(character, blankData); continue; } @@ -403,10 +389,10 @@ namespace Barotrauma } GlyphData newData = new GlyphData( - advance: (float)horizontalAdvance, - texIndex: textures.Count - 1, - texCoords: new Rectangle((int)currentDynamicAtlasCoords.X, (int)currentDynamicAtlasCoords.Y, glyphWidth, glyphHeight), - drawOffset: drawOffset + Advance: (float)horizontalAdvance, + TexIndex: textures.Count - 1, + TexCoords: new Rectangle((int)currentDynamicAtlasCoords.X, (int)currentDynamicAtlasCoords.Y, glyphWidth, glyphHeight), + DrawOffset: drawOffset ); texCoords.Add(character, newData); @@ -490,7 +476,7 @@ namespace Barotrauma return gd; } - return new GlyphData(texIndex: -1); + return new GlyphData(TexIndex: -1); } public void DrawString(SpriteBatch sb, string text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects se, float layerDepth, Alignment alignment = Alignment.TopLeft, ForceUpperCase forceUpperCase = Barotrauma.ForceUpperCase.Inherit) @@ -814,14 +800,22 @@ namespace Barotrauma { Vector2 retVal = Vector2.Zero; retVal.Y = LineHeight; + + var (gd, _) = GetGlyphDataAndTextureForChar(c); + retVal.X = gd.Advance; + return retVal; + } + + public (GlyphData GlyphData, Texture2D Texture) GetGlyphDataAndTextureForChar(char c) + { if (DynamicLoading && !texCoords.ContainsKey(c)) { DynamicRenderAtlas(graphicsDevice, c); } GlyphData gd = GetGlyphData(c); - retVal.X = gd.Advance; - return retVal; + var tex = gd.TexIndex >= 0 ? textures[gd.TexIndex] : null; + return (gd, tex); } public void Dispose() diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs index 4a1bee7c4..cc59e8d2b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUI.cs @@ -199,19 +199,15 @@ namespace Barotrauma public static bool PauseMenuOpen { get; private set; } - public static bool InputBlockingMenuOpen - { - get - { - return PauseMenuOpen || - SettingsMenuOpen || - DebugConsole.IsOpen || - GameSession.IsTabMenuOpen || - GameMain.GameSession?.GameMode is { Paused: true } || - CharacterHUD.IsCampaignInterfaceOpen || - GameMain.GameSession?.Campaign is { SlideshowPlayer: { Finished: false, Visible: true } }; - } - } + public static bool InputBlockingMenuOpen => + PauseMenuOpen + || SettingsMenuOpen + || SocialOverlay.Instance is { IsOpen: true } + || DebugConsole.IsOpen + || GameSession.IsTabMenuOpen + || GameMain.GameSession?.GameMode is { Paused: true } + || CharacterHUD.IsCampaignInterfaceOpen + || GameMain.GameSession?.Campaign is { SlideshowPlayer: { Finished: false, Visible: true } }; public static bool PreventPauseMenuToggle = false; @@ -882,25 +878,30 @@ namespace Barotrauma private static void HandlePersistingElements(float deltaTime) { - lock (mutex) + bool currentMessageBoxIsVerificationPrompt = GUIMessageBox.VisibleBox is GUIMessageBox { DrawOnTop: true }; + + if (!currentMessageBoxIsVerificationPrompt) { GUIMessageBox.AddActiveToGUIUpdateList(); - GUIContextMenu.AddActiveToGUIUpdateList(); + } - if (PauseMenuOpen) - { - PauseMenu.AddToGUIUpdateList(); - } - if (SettingsMenuOpen) - { - SettingsMenuContainer.AddToGUIUpdateList(); - } + if (SettingsMenuOpen) + { + SettingsMenuContainer.AddToGUIUpdateList(); + } + else if (PauseMenuOpen) + { + PauseMenu.AddToGUIUpdateList(); + } - //the "are you sure you want to quit" prompts are drawn on top of everything else - if (GUIMessageBox.VisibleBox?.UserData as string == "verificationprompt" || GUIMessageBox.VisibleBox?.UserData as string == "bugreporter") - { - GUIMessageBox.VisibleBox.AddToGUIUpdateList(); - } + SocialOverlay.Instance?.AddToGuiUpdateList(); + + GUIContextMenu.AddActiveToGUIUpdateList(); + + //the "are you sure you want to quit" prompts are drawn on top of everything else + if (currentMessageBoxIsVerificationPrompt) + { + GUIMessageBox.VisibleBox.AddToGUIUpdateList(); } } @@ -2560,7 +2561,8 @@ namespace Barotrauma var msgBox = new GUIMessageBox("", TextManager.Get(textTag), new LocalizedString[] { TextManager.Get("Yes"), TextManager.Get("No") }) { - UserData = "verificationprompt" + UserData = "verificationprompt", + DrawOnTop = true }; msgBox.Buttons[0].OnClicked = (_, __) => { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs index a824218e5..c5e1a2b81 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIButton.cs @@ -168,7 +168,7 @@ namespace Barotrauma public override bool PlaySoundOnSelect { get; set; } = true; - public GUIButton(RectTransform rectT, Alignment textAlignment = Alignment.Center, string style = "", Color? color = null) : this(rectT, new RawLString(""), textAlignment, style, color) { } + public GUIButton(RectTransform rectT, Alignment textAlignment = Alignment.Center, string style = "", Color? color = null) : this(rectT, LocalizedString.EmptyString, textAlignment, style, color) { } public GUIButton(RectTransform rectT, LocalizedString text, Alignment textAlignment = Alignment.Center, string style = "", Color? color = null) : base(style, rectT) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIColorPicker.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIColorPicker.cs index 4cc0ee1e7..26c441476 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIColorPicker.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIColorPicker.cs @@ -159,7 +159,7 @@ namespace Barotrauma SelectedValue = Math.Clamp(1f - (y / mainArea.Height), 0, 1); } - CurrentColor = ToolBox.HSVToRGB(SelectedHue, SelectedSaturation, SelectedValue); + CurrentColor = ToolBoxCore.HSVToRGB(SelectedHue, SelectedSaturation, SelectedValue); OnColorSelected?.Invoke(this, CurrentColor); } @@ -201,7 +201,7 @@ namespace Barotrauma } } - private Color DrawHVArea(float x, float y) => ToolBox.HSVToRGB(SelectedHue, x, 1.0f - y); - private Color DrawHueArea(float x, float y) => ToolBox.HSVToRGB(y * 360f, 1f, 1f); + private Color DrawHVArea(float x, float y) => ToolBoxCore.HSVToRGB(SelectedHue, x, 1.0f - y); + private Color DrawHueArea(float x, float y) => ToolBoxCore.HSVToRGB(y * 360f, 1f, 1f); } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs index 762c4053c..d26b86738 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIComponent.cs @@ -8,8 +8,7 @@ using System.Xml.Linq; using Barotrauma.IO; using RestSharp; using System.Net; -using System.Collections.Immutable; -using Barotrauma.Tutorials; +using Barotrauma.Steam; namespace Barotrauma { @@ -1174,11 +1173,14 @@ namespace Barotrauma { try { -#if USE_STEAM - Steam.SteamManager.OverlayCustomUrl(url); -#else - ToolBox.OpenFileWithShell(url); -#endif + if (SteamManager.IsInitialized) + { + SteamManager.OverlayCustomUrl(url); + } + else + { + ToolBox.OpenFileWithShell(url); + } } catch (Exception e) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIContextMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIContextMenu.cs index 3e9166fcd..bc59543f9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIContextMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIContextMenu.cs @@ -76,12 +76,12 @@ namespace Barotrauma if (hasHeader) { - InflateSize(ref estimatedSize, header, headerFont); + InflateSize(ref estimatedSize, header, headerFont!); } foreach (ContextMenuOption option in options) { - Vector2 optionSize = InflateSize(ref estimatedSize, option.Label, font); + Vector2 optionSize = InflateSize(ref estimatedSize, option.Label, font!); optionsAndSizes.Add(option, optionSize); } @@ -104,7 +104,7 @@ namespace Barotrauma if (hasHeader) { Point sz = Point.Zero; - InflateSize(ref sz, header, headerFont); + InflateSize(ref sz, header, headerFont!); listSize.Y -= sz.Y; HeaderLabel = new GUITextBlock(new RectTransform(sz, background.RectTransform), header, font: headerFont) { Padding = headerPadding }; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs index 8a795ccd2..5185b7b63 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIDropDown.cs @@ -168,7 +168,7 @@ namespace Barotrauma public GUIDropDown(RectTransform rectT, LocalizedString text = null, int elementCount = 4, string style = "", bool selectMultiple = false, bool dropAbove = false, Alignment textAlignment = Alignment.CenterLeft) : base(style, rectT) { - text ??= new RawLString(""); + text ??= LocalizedString.EmptyString; HoverCursor = CursorState.Hand; CanBeFocused = true; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUILayoutGroup.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUILayoutGroup.cs index d21951cc1..5ca757417 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUILayoutGroup.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUILayoutGroup.cs @@ -205,5 +205,11 @@ namespace Barotrauma Recalculate(); } } + + public override void ForceLayoutRecalculation() + { + Recalculate(); + base.ForceLayoutRecalculation(); + } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs index de557c274..c340cf1ad 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIMessageBox.cs @@ -86,6 +86,11 @@ namespace Barotrauma public Type MessageBoxType => type; + /// + /// If enabled, the box is always drawn in front of all other elements. + /// + public bool DrawOnTop; + public static GUIComponent VisibleBox => MessageBoxes.LastOrDefault(); public GUIMessageBox(LocalizedString headerText, LocalizedString text, Vector2? relativeSize = null, Point? minSize = null, Type type = Type.Default) @@ -477,13 +482,13 @@ namespace Barotrauma for (int i = 0; i < MessageBoxes.Count; i++) { if (MessageBoxes[i] == null) { continue; } - if (!(MessageBoxes[i] is GUIMessageBox messageBox)) + if (MessageBoxes[i] is not GUIMessageBox messageBox) { if (type == Type.Default) { // Message box not of type GUIMessageBox is likely the round summary MessageBoxes[i].AddToGUIUpdateList(); - if (!(MessageBoxes[i].UserData is RoundSummary)) { break; } + if (MessageBoxes[i].UserData is not RoundSummary) { break; } } continue; } @@ -494,8 +499,7 @@ namespace Barotrauma } // These are handled separately in GUI.HandlePersistingElements() - if (MessageBoxes[i].UserData as string == "verificationprompt") { continue; } - if (MessageBoxes[i].UserData as string == "bugreporter") { continue; } + if (messageBox.DrawOnTop) { continue; } messageBox.AddToGUIUpdateList(); break; diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIPrefab.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIPrefab.cs index d1d4a470f..3080c482e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIPrefab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUIPrefab.cs @@ -1,4 +1,5 @@ -using Barotrauma.Extensions; +#nullable enable +using Barotrauma.Extensions; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -54,8 +55,8 @@ namespace Barotrauma public class GUIFontPrefab : GUIPrefab { private readonly ContentXElement element; - private ScalableFont font; - public ScalableFont Font + private ScalableFont? font; + public ScalableFont? Font { get { @@ -64,11 +65,13 @@ namespace Barotrauma } } - private ImmutableDictionary specialHandlingFonts; + private ImmutableDictionary? specialHandlingFonts; - public ScalableFont GetFontForCategory(TextManager.SpeciallyHandledCharCategory category) + public ScalableFont? GetFontForCategory(TextManager.SpeciallyHandledCharCategory category) { if (Language != GameSettings.CurrentConfig.Language) { LoadFont(); } + if (font is null) { return null; } + if (specialHandlingFonts is null) { return font; } if (font.SpeciallyHandledCharCategory.HasFlag(category)) { return font; } return specialHandlingFonts.TryGetValue(category, out var resultFont) ? resultFont @@ -87,7 +90,7 @@ namespace Barotrauma public void LoadFont() { - string fontPath = GetFontFilePath(element); + string? fontPath = GetFontFilePath(element); uint size = GetFontSize(element); bool dynamicLoading = GetFontDynamicLoading(element); var shcc = GetShcc(element); @@ -125,21 +128,21 @@ namespace Barotrauma specialHandlingFonts = null; } - private ScalableFont ExtractFont(TextManager.SpeciallyHandledCharCategory flag, ContentXElement element) + private ScalableFont? ExtractFont(TextManager.SpeciallyHandledCharCategory flag, ContentXElement element) { foreach (var subElement in element.Elements().Reverse()) { if (subElement.NameAsIdentifier() != "override") { continue; } if (ScalableFont.ExtractShccFromXElement(subElement).HasFlag(flag)) { - return new ScalableFont(subElement, font.Size, GameMain.Instance.GraphicsDevice); + return new ScalableFont(subElement, font?.Size ?? 14, GameMain.Instance.GraphicsDevice); } } ScalableFont hardcodedFallback(string path) => new ScalableFont( path, - font.Size, + font?.Size ?? 0, GameMain.Instance.GraphicsDevice, dynamicLoading: true, speciallyHandledCharCategory: flag); @@ -154,7 +157,7 @@ namespace Barotrauma }; } - private string GetFontFilePath(ContentXElement element) + private string? GetFontFilePath(ContentXElement element) { foreach (var subElement in element.Elements()) { @@ -227,20 +230,20 @@ namespace Barotrauma { public GUIFont(string identifier) : base(identifier) { } - public bool HasValue => !Prefabs.IsEmpty; - - public ScalableFont Value => Prefabs.ActivePrefab.Font; + public bool HasValue => Value is not null; - public static implicit operator ScalableFont(GUIFont reference) => reference.Value; + public ScalableFont? Value => Prefabs.ActivePrefab?.Font; + + public static implicit operator ScalableFont?(GUIFont reference) => reference.Value; public bool ForceUpperCase => Prefabs.ActivePrefab?.Font is { ForceUpperCase: true }; - public uint Size => HasValue ? Value.Size : 0; + public uint Size => Value?.Size ?? 0; - private ScalableFont GetFontForStr(LocalizedString str) => GetFontForStr(str.Value); + private ScalableFont? GetFontForStr(LocalizedString str) => GetFontForStr(str.Value); - public ScalableFont GetFontForStr(string str) => - Prefabs.ActivePrefab.GetFontForCategory(TextManager.GetSpeciallyHandledCategories(str)); + public ScalableFont? GetFontForStr(string str) => + Prefabs.ActivePrefab?.GetFontForCategory(TextManager.GetSpeciallyHandledCategories(str)); public void DrawString(SpriteBatch sb, LocalizedString text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects spriteEffects, float layerDepth) { @@ -249,7 +252,7 @@ namespace Barotrauma public void DrawString(SpriteBatch sb, string text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects spriteEffects, float layerDepth) { - GetFontForStr(text).DrawString(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth); + GetFontForStr(text)?.DrawString(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth); } public void DrawString(SpriteBatch sb, LocalizedString text, Vector2 position, Color color, float rotation, Vector2 origin, float scale, SpriteEffects spriteEffects, float layerDepth, Alignment alignment = Alignment.TopLeft) @@ -259,7 +262,7 @@ namespace Barotrauma public void DrawString(SpriteBatch sb, string text, Vector2 position, Color color, float rotation, Vector2 origin, float scale, SpriteEffects spriteEffects, float layerDepth, Alignment alignment = Alignment.TopLeft, ForceUpperCase forceUpperCase = Barotrauma.ForceUpperCase.Inherit) { - GetFontForStr(text).DrawString(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth, alignment, forceUpperCase); + GetFontForStr(text)?.DrawString(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth, alignment, forceUpperCase); } public void DrawString(SpriteBatch sb, LocalizedString text, Vector2 position, Color color, ForceUpperCase forceUpperCase = Barotrauma.ForceUpperCase.Inherit, bool italics = false) @@ -269,34 +272,46 @@ namespace Barotrauma public void DrawString(SpriteBatch sb, string text, Vector2 position, Color color, ForceUpperCase forceUpperCase = Barotrauma.ForceUpperCase.Inherit, bool italics = false) { - GetFontForStr(text).DrawString(sb, text, position, color, forceUpperCase, italics); + GetFontForStr(text)?.DrawString(sb, text, position, color, forceUpperCase, italics); } public void DrawStringWithColors(SpriteBatch sb, string text, Vector2 position, Color color, float rotation, Vector2 origin, float scale, SpriteEffects spriteEffects, float layerDepth, in ImmutableArray? richTextData, int rtdOffset = 0, Alignment alignment = Alignment.TopLeft, ForceUpperCase forceUpperCase = Barotrauma.ForceUpperCase.Inherit) { - GetFontForStr(text).DrawStringWithColors(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth, richTextData, rtdOffset, alignment, forceUpperCase); + GetFontForStr(text)?.DrawStringWithColors(sb, text, position, color, rotation, origin, scale, spriteEffects, layerDepth, richTextData, rtdOffset, alignment, forceUpperCase); } public Vector2 MeasureString(LocalizedString str, bool removeExtraSpacing = false) { - return GetFontForStr(str).MeasureString(str, removeExtraSpacing); + return GetFontForStr(str)?.MeasureString(str, removeExtraSpacing) ?? Vector2.Zero; } public Vector2 MeasureChar(char c) { - return GetFontForStr($"{c}").MeasureChar(c); + return GetFontForStr($"{c}")?.MeasureChar(c) ?? Vector2.Zero; } public string WrapText(string text, float width) - => GetFontForStr(text).WrapText(text, width); + => GetFontForStr(text)?.WrapText(text, width) ?? text; public string WrapText(string text, float width, int requestCharPos, out Vector2 requestedCharPos) - => GetFontForStr(text).WrapText(text, width, requestCharPos, out requestedCharPos); - - public string WrapText(string text, float width, out Vector2[] allCharPositions) - => GetFontForStr(text).WrapText(text, width, out allCharPositions); + { + requestedCharPos = default; + return GetFontForStr(text)?.WrapText(text, width, requestCharPos, out requestedCharPos) ?? text; + } - public float LineHeight => Value.LineHeight; + public string WrapText(string text, float width, out Vector2[] allCharPositions) + { + var scalableFont = GetFontForStr(text); + if (scalableFont != null) + { + return scalableFont.WrapText(text, width, out allCharPositions); + } + + allCharPositions = Enumerable.Range(0, text.Length + 1).Select(_ => Vector2.Zero).ToArray(); + return text; + } + + public float LineHeight => Value?.LineHeight ?? 0; } public class GUIColorPrefab : GUIPrefab @@ -355,19 +370,19 @@ namespace Barotrauma { public GUISprite(string identifier) : base(identifier) { } - public UISprite Value + public UISprite? Value { get { - return Prefabs.ActivePrefab.Sprite; + return Prefabs.ActivePrefab?.Sprite; } } - public static implicit operator UISprite(GUISprite reference) => reference.Value; + public static implicit operator UISprite?(GUISprite reference) => reference.Value; public void Draw(SpriteBatch spriteBatch, Rectangle rect, Color color, SpriteEffects spriteEffects = SpriteEffects.None) { - Value.Draw(spriteBatch, rect, color, spriteEffects); + Value?.Draw(spriteBatch, rect, color, spriteEffects); } } @@ -390,33 +405,33 @@ namespace Barotrauma { public GUISpriteSheet(string identifier) : base(identifier) { } - public SpriteSheet Value + public SpriteSheet? Value { get { - return Prefabs.ActivePrefab.SpriteSheet; + return Prefabs.ActivePrefab?.SpriteSheet; } } - public int FrameCount => Value.FrameCount; - public Point FrameSize => Value.FrameSize; + public int FrameCount => Value?.FrameCount ?? 1; + public Point FrameSize => Value?.FrameSize ?? Point.Zero; public void Draw(ISpriteBatch spriteBatch, Vector2 pos, float rotate = 0, float scale = 1, SpriteEffects spriteEffects = SpriteEffects.None) { - Value.Draw(spriteBatch, pos, rotate, scale, spriteEffects); + Value?.Draw(spriteBatch, pos, rotate, scale, spriteEffects); } public void Draw(ISpriteBatch spriteBatch, Vector2 pos, Color color, Vector2 origin, float rotate = 0, float scale = 1, SpriteEffects spriteEffects = SpriteEffects.None, float? depth = null) { - Value.Draw(spriteBatch, pos, color, origin, rotate, scale, spriteEffects, depth); + Value?.Draw(spriteBatch, pos, color, origin, rotate, scale, spriteEffects, depth); } public void Draw(ISpriteBatch spriteBatch, int spriteIndex, Vector2 pos, Color color, Vector2 origin, float rotate, Vector2 scale, SpriteEffects spriteEffects = SpriteEffects.None, float? depth = null) { - Value.Draw(spriteBatch, spriteIndex, pos, color, origin, rotate, scale, spriteEffects, depth); + Value?.Draw(spriteBatch, spriteIndex, pos, color, origin, rotate, scale, spriteEffects, depth); } - public static implicit operator SpriteSheet(GUISpriteSheet reference) => reference.Value; + public static implicit operator SpriteSheet?(GUISpriteSheet reference) => reference.Value; } public class GUICursorPrefab : GUIPrefab @@ -446,6 +461,6 @@ namespace Barotrauma { public GUICursor(string identifier) : base(identifier) { } - public Sprite this[CursorState k] => Prefabs.ActivePrefab.Sprites[(int)k]; + public Sprite? this[CursorState k] => Prefabs.ActivePrefab?.Sprites[(int)k]; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBlock.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBlock.cs index c9b9d0626..4aa48fd6d 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBlock.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/GUITextBlock.cs @@ -433,14 +433,7 @@ namespace Barotrauma return Font.MeasureString(" "); } - Vector2 size = Vector2.Zero; - while (size == Vector2.Zero) - { - try { size = Font.MeasureString(string.IsNullOrEmpty(text) ? " " : text); } - catch { text = text.Length > 0 ? text.Substring(0, text.Length - 1) : ""; } - } - - return size; + return Font.MeasureString(string.IsNullOrEmpty(text) ? " " : text); } protected override void SetAlpha(float a) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GUI/VideoPlayer.cs b/Barotrauma/BarotraumaClient/ClientSource/GUI/VideoPlayer.cs index 6152fa2dc..9be5c0d1f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GUI/VideoPlayer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GUI/VideoPlayer.cs @@ -128,7 +128,7 @@ namespace Barotrauma public void LoadContent(string contentPath, VideoSettings videoSettings, TextSettings textSettings, Identifier contentId, bool startPlayback) { - LoadContent(contentPath, videoSettings, textSettings, contentId, startPlayback, new RawLString(""), null); + LoadContent(contentPath, videoSettings, textSettings, contentId, startPlayback, LocalizedString.EmptyString, null); } public void LoadContent(string contentPath, VideoSettings videoSettings, TextSettings textSettings, Identifier contentId, bool startPlayback, LocalizedString objective, Action onStop = null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameAnalytics/GameAnalyticsManager.cs b/Barotrauma/BarotraumaClient/ClientSource/GameAnalytics/GameAnalyticsManager.cs index 7ffcbfde4..1cd2bf977 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameAnalytics/GameAnalyticsManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameAnalytics/GameAnalyticsManager.cs @@ -9,7 +9,7 @@ namespace Barotrauma { static partial void CreateConsentPrompt() { - if (consentTextAvailable) + if (ConsentTextAvailable) { var background = new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, GUI.Canvas), style: "GUIBackgroundBlocker"); var frame = new GUIFrame(new RectTransform(new Vector2(0.5f, 0.7f), background.RectTransform, Anchor.Center) { MinSize = new Point(800, 0), MaxSize = new Point(1500, int.MaxValue) }); @@ -20,8 +20,13 @@ namespace Barotrauma AbsoluteSpacing = GUI.IntScale(15) }; + string consentTextTag = "statisticsconsenttext"; + if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + consentTextTag = "statisticsconsenteostext"; + } new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), TextManager.Get("statisticsconsentheader"), font: GUIStyle.SubHeadingFont, textColor: Color.White); - var mainText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), RichString.Rich(TextManager.Get("statisticsconsenttext")), wrap: true); + var mainText = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), content.RectTransform), RichString.Rich(TextManager.Get(consentTextTag)), wrap: true); foreach (var data in mainText.RichTextData) { @@ -30,7 +35,7 @@ namespace Barotrauma Data = data, OnClick = (GUITextBlock component, GUITextBlock.ClickableArea area) => { - GameMain.ShowOpenUrlInWebBrowserPrompt("https://gameanalytics.com/privacy/"); + GameMain.ShowOpenUriPrompt("https://gameanalytics.com/privacy/"); } }); } @@ -70,7 +75,7 @@ namespace Barotrauma } yield return CoroutineStatus.Success; } - + buttonContainerSpacing(0.2f); var noBtn = new GUIButton(new RectTransform(new Vector2(0.3f, 1.0f), buttonContainer.RectTransform), TextManager.Get("No")); diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs b/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs index 50dbc4178..57dd6dea9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameMain.cs @@ -44,7 +44,9 @@ namespace Barotrauma public static readonly Version Version = Assembly.GetEntryAssembly().GetName().Version; - public static string[] ConsoleArguments; + public readonly ImmutableArray ConsoleArguments; + + public readonly Option EgsExchangeCode; public static GameScreen GameScreen; public static MainMenuScreen MainMenuScreen; @@ -214,6 +216,10 @@ namespace Barotrauma public static ChatMode ActiveChatMode { get; set; } = ChatMode.Radio; + private static bool contentLoaded; + + private static readonly Queue postContentLoadActions = new(); + public GameMain(string[] args) { Content.RootDirectory = "Content"; @@ -239,11 +245,13 @@ namespace Barotrauma GameSettings.Init(); CreatureMetrics.Init(); - ConsoleArguments = args; + ConsoleArguments = args.ToImmutableArray(); + + EgsExchangeCode = EosInterface.Login.ParseEgsExchangeCode(args); try { - ConnectCommand = ToolBox.ParseConnectCommand(ConsoleArguments); + ConnectCommand = Barotrauma.Networking.ConnectCommand.Parse(ConsoleArguments); } catch (IndexOutOfRangeException e) { @@ -270,6 +278,16 @@ namespace Barotrauma Window.FileDropped += OnFileDropped; } + public static void ExecuteAfterContentFinishedLoading(Action action) + { + if (contentLoaded) + { + action(); + return; + } + postContentLoadActions.Enqueue(action); + } + public static void OnFileDropped(object sender, FileDropEventArgs args) { if (!(Screen.Selected is { } screen)) { return; } @@ -416,6 +434,8 @@ namespace Barotrauma WaitForLanguageSelection = GameSettings.CurrentConfig.Language == LanguageIdentifier.None }; + Eos.EosAccount.LoginPlatformSpecific(); + initialLoadingThread = new Thread(Load); initialLoadingThread.Start(); } @@ -483,6 +503,7 @@ namespace Barotrauma TextManager.VerifyLanguageAvailable(); + SocialOverlay.Init(); DebugConsole.Init(); ContentPackageManager.LogEnabledRegularPackageErrors(); @@ -514,25 +535,27 @@ namespace Barotrauma TitleScreen.LoadState = 85.0f; -#if USE_STEAM if (SteamManager.IsInitialized) { - Steamworks.SteamFriends.OnGameRichPresenceJoinRequested += OnInvitedToGame; - Steamworks.SteamFriends.OnGameLobbyJoinRequested += OnLobbyJoinRequested; + Steamworks.SteamFriends.OnGameRichPresenceJoinRequested += OnInvitedToSteamGame; + Steamworks.SteamFriends.OnGameLobbyJoinRequested += OnSteamLobbyJoinRequested; if (SteamManager.TryGetUnlockedAchievements(out List achievements)) { //check the achievements too, so we don't consider people who've played the game before this "gamelaunchcount" stat was added as being 1st-time-players //(people who have played previous versions, but not unlocked any achievements, will be incorrectly considered 1st-time-players, but that should be a small enough group to not skew the statistics) - if (!achievements.Any() && SteamManager.GetStatInt("gamelaunchcount".ToIdentifier()) <= 0) + if (!achievements.Any() && SteamManager.GetStatInt(AchievementStat.GameLaunchCount) <= 0) { IsFirstLaunch = true; GameAnalyticsManager.AddDesignEvent("FirstLaunch"); } } - SteamManager.IncrementStat("gamelaunchcount".ToIdentifier(), 1); + SteamManager.IncrementStat(AchievementStat.GameLaunchCount, 1); } -#endif + + + + Eos.EosAccount.ExecuteAfterLogin(ProcessLaunchCountEos); SubEditorScreen = new SubEditorScreen(); TestScreen = new TestScreen(); @@ -564,7 +587,45 @@ namespace Barotrauma TitleScreen.LoadState = 100.0f; HasLoaded = true; + log("LOADING COROUTINE FINISHED"); + + contentLoaded = true; + while (postContentLoadActions.TryDequeue(out Action action)) + { + action(); + } + } + + private static void ProcessLaunchCountEos() + { + if (!EosInterface.Core.IsInitialized) { return; } + + static void trySetConnectCommand(string commandStr) + { + Instance.ConnectCommand = Instance.ConnectCommand.Fallback(Networking.ConnectCommand.Parse(commandStr)); + } + + EosInterface.Presence.OnJoinGame.Register("onJoinGame".ToIdentifier(), static jgi => trySetConnectCommand(jgi.JoinCommand)); + EosInterface.Presence.OnInviteAccepted.Register("onInviteAccepted".ToIdentifier(), static aii => trySetConnectCommand(aii.JoinCommand)); + + TaskPool.AddWithResult("Eos.GameMain.Load.QueryStats", EosInterface.Achievements.QueryStats(AchievementStat.GameLaunchCount), static result => + { + result.Match( + success: static stats => + { + if (!stats.TryGetValue(AchievementStat.GameLaunchCount, out int launchCount)) { return; } + + if (launchCount > 0) { return; } + + IsFirstLaunch = true; + GameAnalyticsManager.AddDesignEvent("FirstLaunch_Epic"); + }, + failure: static error => DebugConsole.ThrowError($"Failed to query stats for launch count: {error}") + ); + + TaskPool.Add("Eos.GameMain.Load.IngestStat", EosInterface.Achievements.IngestStats((AchievementStat.GameLaunchCount, 1)), TaskPool.IgnoredCallback); + }); } /// @@ -581,13 +642,13 @@ namespace Barotrauma MainThread = null; } - public void OnInvitedToGame(Steamworks.Friend friend, string connectCommand) => OnInvitedToGame(connectCommand); + private void OnInvitedToSteamGame(Steamworks.Friend friend, string connectCommand) => OnInvitedToSteamGame(connectCommand); - public void OnInvitedToGame(string connectCommand) + private void OnInvitedToSteamGame(string connectCommand) { try { - ConnectCommand = ToolBox.ParseConnectCommand(ToolBox.SplitCommand(connectCommand)); + ConnectCommand = Barotrauma.Networking.ConnectCommand.Parse(ToolBox.SplitCommand(connectCommand)); } catch (IndexOutOfRangeException e) { @@ -600,7 +661,7 @@ namespace Barotrauma } } - public void OnLobbyJoinRequested(Steamworks.Data.Lobby lobby, Steamworks.SteamId friendId) + private void OnSteamLobbyJoinRequested(Steamworks.Data.Lobby lobby, Steamworks.SteamId friendId) { SteamManager.JoinLobby(lobby.Id, true); } @@ -655,6 +716,8 @@ namespace Barotrauma PlayerInput.Update(Timing.Step); + SocialOverlay.Instance?.Update(); + if (loadingScreenOpen) { //reset accumulator if loading @@ -729,16 +792,16 @@ namespace Barotrauma } MainMenuScreen.Select(); - if (connectCommand.EndpointOrLobby.TryGet(out ulong lobbyId)) + if (connectCommand.SteamLobbyIdOption.TryUnwrap(out var lobbyId)) { - SteamManager.JoinLobby(lobbyId, joinServer: true); + SteamManager.JoinLobby(lobbyId.Value, joinServer: true); } - else if (connectCommand.EndpointOrLobby.TryGet(out ConnectCommand.NameAndEndpoint nameAndEndpoint) - && nameAndEndpoint is { ServerName: var serverName, Endpoint: var endpoint }) + else if (connectCommand.NameAndP2PEndpointsOption.TryUnwrap(out var nameAndEndpoint) + && nameAndEndpoint is { ServerName: var serverName, Endpoints: var endpoints }) { Client = new GameClient(MultiplayerPreferences.Instance.PlayerName.FallbackNullOrEmpty(SteamManager.GetUsername()), - endpoint, - string.IsNullOrWhiteSpace(serverName) ? endpoint.StringRepresentation : serverName, + endpoints.Cast().ToImmutableArray(), + string.IsNullOrWhiteSpace(serverName) ? endpoints.First().StringRepresentation : serverName, Option.None()); } @@ -747,6 +810,18 @@ namespace Barotrauma SoundPlayer.Update((float)Timing.Step); + if ((PlayerInput.KeyDown(Keys.LeftControl) || PlayerInput.KeyDown(Keys.RightControl)) + && (PlayerInput.KeyDown(Keys.LeftShift) || PlayerInput.KeyDown(Keys.RightShift)) + && PlayerInput.KeyHit(Keys.Tab) + && SocialOverlay.Instance is { } socialOverlay) + { + socialOverlay.IsOpen = !socialOverlay.IsOpen; + if (socialOverlay.IsOpen) + { + socialOverlay.RefreshFriendList(); + } + } + if (PlayerInput.KeyHit(Keys.Escape) && WindowActive) { // Check if a text input is selected. @@ -758,15 +833,16 @@ namespace Barotrauma } GUI.KeyboardDispatcher.Subscriber = null; } + else if (SocialOverlay.Instance is { IsOpen: true }) + { + SocialOverlay.Instance.IsOpen = false; + } //if a verification prompt (are you sure you want to x) is open, close it - else if (GUIMessageBox.VisibleBox as GUIMessageBox != null && - GUIMessageBox.VisibleBox.UserData as string == "verificationprompt") + else if (GUIMessageBox.VisibleBox is GUIMessageBox { UserData: "verificationprompt" }) { ((GUIMessageBox)GUIMessageBox.VisibleBox).Close(); } - else if (GUIMessageBox.VisibleBox?.UserData is RoundSummary roundSummary && - roundSummary.ContinueButton != null && - roundSummary.ContinueButton.Visible) + else if (GUIMessageBox.VisibleBox?.UserData is RoundSummary { ContinueButton.Visible: true }) { GUIMessageBox.MessageBoxes.Remove(GUIMessageBox.VisibleBox); } @@ -778,8 +854,7 @@ namespace Barotrauma { gameSession.ToggleTabMenu(); } - else if (GUIMessageBox.VisibleBox as GUIMessageBox != null && - GUIMessageBox.VisibleBox.UserData as string == "bugreporter") + else if (GUIMessageBox.VisibleBox is GUIMessageBox { UserData: "bugreporter" }) { ((GUIMessageBox)GUIMessageBox.VisibleBox).Close(); } @@ -904,6 +979,7 @@ namespace Barotrauma CoroutineManager.Update(Paused, (float)Timing.Step); SteamManager.Update((float)Timing.Step); + EosInterface.Core.Update(); TaskPool.Update(); @@ -1097,14 +1173,16 @@ namespace Barotrauma public void ShowBugReporter() { - if (GUIMessageBox.VisibleBox != null && GUIMessageBox.VisibleBox.UserData as string == "bugreporter") + if (GUIMessageBox.VisibleBox != null && + GUIMessageBox.VisibleBox.UserData as string == "bugreporter") { return; } var msgBox = new GUIMessageBox(TextManager.Get("bugreportbutton"), "") { - UserData = "bugreporter" + UserData = "bugreporter", + DrawOnTop = true }; var linkHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 1.0f), msgBox.Content.RectTransform)) { Stretch = true, RelativeSpacing = 0.025f }; linkHolder.RectTransform.MaxSize = new Point(int.MaxValue, linkHolder.Rect.Height); @@ -1117,7 +1195,7 @@ namespace Barotrauma { if (!SteamManager.OverlayCustomUrl(userdata as string)) { - ShowOpenUrlInWebBrowserPrompt(userdata as string); + ShowOpenUriPrompt(userdata as string); } msgBox.Close(); return true; @@ -1130,7 +1208,7 @@ namespace Barotrauma UserData = "https://github.com/Regalis11/Barotrauma/issues/new/choose", OnClicked = (btn, userdata) => { - ShowOpenUrlInWebBrowserPrompt(userdata as string); + ShowOpenUriPrompt(userdata as string); msgBox.Close(); return true; } @@ -1173,19 +1251,23 @@ namespace Barotrauma base.OnExiting(sender, args); } - public static void ShowOpenUrlInWebBrowserPrompt(string url, string promptExtensionTag = null) + public static GUIMessageBox ShowOpenUriPrompt(string url, string promptTextTag = "openlinkinbrowserprompt", string promptExtensionTag = null) { - if (string.IsNullOrEmpty(url)) { return; } - if (GUIMessageBox.VisibleBox?.UserData as string == "verificationprompt") { return; } - - LocalizedString text = TextManager.GetWithVariable("openlinkinbrowserprompt", "[link]", url); + LocalizedString text = TextManager.GetWithVariable(promptTextTag, "[link]", url); LocalizedString extensionText = TextManager.Get(promptExtensionTag); if (!extensionText.IsNullOrEmpty()) { text += $"\n\n{extensionText}"; } + return ShowOpenUriPrompt(url, text); + } - var msgBox = new GUIMessageBox("", text, new LocalizedString[] { TextManager.Get("Yes"), TextManager.Get("No") }) + public static GUIMessageBox ShowOpenUriPrompt(string url, LocalizedString promptText) + { + if (string.IsNullOrEmpty(url)) { return null; } + if (GUIMessageBox.VisibleBox?.UserData as string == "verificationprompt") { return null; } + + var msgBox = new GUIMessageBox("", promptText, new LocalizedString[] { TextManager.Get("Yes"), TextManager.Get("No") }) { UserData = "verificationprompt" }; @@ -1203,6 +1285,7 @@ namespace Barotrauma return true; }; msgBox.Buttons[1].OnClicked = msgBox.Close; + return msgBox; } /* diff --git a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs index cc4695375..803e3c275 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/GameSession/GameModes/SinglePlayerCampaign.cs @@ -175,13 +175,11 @@ namespace Barotrauma if (CheatsEnabled) { DebugConsole.CheatsEnabled = true; -#if USE_STEAM - if (!SteamAchievementManager.CheatsEnabled) + if (!AchievementManager.CheatsEnabled) { - SteamAchievementManager.CheatsEnabled = true; + AchievementManager.CheatsEnabled = true; new GUIMessageBox("Cheats enabled", "Cheat commands have been enabled on the campaign. You will not receive Steam Achievements until you restart the game."); } -#endif } if (map == null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs index 7f81d3a3c..61cf70993 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/ItemLabel.cs @@ -93,13 +93,13 @@ namespace Barotrauma.Items.Components [Editable(0.0f, 10.0f), Serialize(1.0f, IsPropertySaveable.Yes, description: "The scale of the text displayed on the label.", alwaysUseInstanceValues: true)] public float TextScale { - get { return textBlock == null ? 1.0f : textBlock.TextScale; } + get { return textBlock == null ? 1.0f : textBlock.TextScale / BaseToRealTextScaleFactor; } set { if (textBlock != null) { float prevScale = TextBlock.TextScale; - textBlock.TextScale = MathHelper.Clamp(value, 0.1f, 10.0f); + textBlock.TextScale = MathHelper.Clamp(value * BaseToRealTextScaleFactor, 0.1f, 10.0f); if (!MathUtils.NearlyEqual(prevScale, TextBlock.TextScale)) { SetScrollingText(); @@ -210,6 +210,8 @@ namespace Barotrauma.Items.Components SetScrollingText(); } + private const float BaseTextSize = 12.0f; + private float BaseToRealTextScaleFactor => BaseTextSize / GUIStyle.UnscaledSmallFont.Size; private void RecreateTextBlock() { textBlock = new GUITextBlock(new RectTransform(item.Rect.Size), "", @@ -217,7 +219,7 @@ namespace Barotrauma.Items.Components { TextDepth = item.SpriteDepth - 0.00001f, RoundToNearestPixel = false, - TextScale = TextScale, + TextScale = TextScale * BaseToRealTextScaleFactor, Padding = padding * item.Scale }; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs index 909becc9a..48b20a156 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Items/Components/Machines/MiniMap.cs @@ -785,7 +785,7 @@ namespace Barotrauma.Items.Components if (item.CurrentHull is { } currentHull && currentHull == hull) { - Sprite pingCircle = GUIStyle.YouAreHereCircle.Value.Sprite; + Sprite? pingCircle = GUIStyle.YouAreHereCircle.Value?.Sprite; if (pingCircle is null) { continue; } Vector2 charPos = item.WorldPosition; @@ -1241,7 +1241,8 @@ namespace Barotrauma.Items.Components foreach (Vector2 blip in MiniMapBlips) { Vector2 parentSize = miniMapFrame.Rect.Size.ToVector2(); - Sprite pingCircle = GUIStyle.PingCircle.Value.Sprite; + Sprite? pingCircle = GUIStyle.PingCircle.Value?.Sprite; + if (pingCircle is null) { continue; } Vector2 targetSize = new Vector2(parentSize.X / 4f); Vector2 spriteScale = targetSize / pingCircle.size; float scale = Math.Min(blipState, maxBlipState / 2f); @@ -1525,7 +1526,7 @@ namespace Barotrauma.Items.Components float maxWidth = Math.Max(sizeX, sizeY); Vector2 drawPos = new Vector2(frame.Rect.Right - sizeX, frame.Rect.Y - sizeY / 2f); - UISprite icon = GUIStyle.IconOverflowIndicator; + UISprite? icon = GUIStyle.IconOverflowIndicator; if (icon != null) { const int iconPadding = 4; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs index 4de4c7810..4c789dcf7 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Lights/LightManager.cs @@ -381,7 +381,7 @@ namespace Barotrauma.Lights spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.Additive, transformMatrix: spriteBatchTransform); Vector3 glowColorHSV = ToolBox.RGBToHSV(AmbientLight); glowColorHSV.Z = Math.Max(glowColorHSV.Z, 0.4f); - Color glowColor = ToolBox.HSVToRGB(glowColorHSV.X, glowColorHSV.Y, glowColorHSV.Z); + Color glowColor = ToolBoxCore.HSVToRGB(glowColorHSV.X, glowColorHSV.Y, glowColorHSV.Z); Vector2 glowSpriteSize = new Vector2(gapGlowTexture.Width, gapGlowTexture.Height); foreach (var gap in Gap.GapList) { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs index 3b847fece..92beaecdb 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Map.cs @@ -184,7 +184,8 @@ namespace Barotrauma connection.CrackSegments.Clear(); connection.CrackSegments.AddRange(MathUtils.GenerateJaggedLine( connectionStart, connectionEnd, - iterations, connectionLength * generationParams.ConnectionIndicatorDisplacementMultiplier)); + iterations, connectionLength * generationParams.ConnectionIndicatorDisplacementMultiplier, + rng: Rand.GetRNG(Rand.RandSync.ServerAndClient))); } private void LocationChanged(Location prevLocation, Location newLocation) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs index 5f8fdb8ef..8fa773813 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/Map/Radiation.cs @@ -9,8 +9,8 @@ namespace Barotrauma { private static readonly LocalizedString radiationTooltip = TextManager.Get("RadiationTooltip"); private static float spriteIndex; - private readonly SpriteSheet sheet = GUIStyle.RadiationAnimSpriteSheet; - private int maxFrames => sheet.FrameCount + 1; + private readonly SpriteSheet? sheet = GUIStyle.RadiationAnimSpriteSheet; + private int maxFrames => (sheet?.FrameCount ?? 0) + 1; private bool isHovingOver; @@ -18,7 +18,7 @@ namespace Barotrauma { if (!Enabled) { return; } - UISprite uiSprite = GUIStyle.Radiation; + UISprite? uiSprite = GUIStyle.Radiation; var (offsetX, offsetY) = Map.DrawOffset * zoom; var (centerX, centerY) = container.Center.ToVector2(); var (halfSizeX, halfSizeY) = new Vector2(container.Width / 2f, container.Height / 2f) * zoom; @@ -29,18 +29,21 @@ namespace Barotrauma Vector2 spriteScale = new Vector2(zoom); - uiSprite.Sprite.DrawTiled(spriteBatch, topLeft, size, color: Params.RadiationAreaColor, startOffset: Vector2.Zero, textureScale: spriteScale); + uiSprite?.Sprite.DrawTiled(spriteBatch, topLeft, size, color: Params.RadiationAreaColor, startOffset: Vector2.Zero, textureScale: spriteScale); Vector2 topRight = topLeft + Vector2.UnitX * size.X; int index = 0; - for (float i = 0; i <= size.Y; i += sheet.FrameSize.Y / 2f * zoom) + if (sheet != null) { - bool isEven = ++index % 2 == 0; - Vector2 origin = new Vector2(0.5f, 0) * sheet.FrameSize.X; - // every other sprite's animation is reversed to make it seem more chaotic - int sprite = (int) MathF.Floor(isEven ? spriteIndex : maxFrames - spriteIndex); - sheet.Draw(spriteBatch, sprite, topRight + new Vector2(0, i), Params.RadiationBorderTint, origin, 0f, spriteScale); + for (float i = 0; i <= size.Y; i += sheet.FrameSize.Y / 2f * zoom) + { + bool isEven = ++index % 2 == 0; + Vector2 origin = new Vector2(0.5f, 0) * sheet.FrameSize.X; + // every other sprite's animation is reversed to make it seem more chaotic + int sprite = (int) MathF.Floor(isEven ? spriteIndex : maxFrames - spriteIndex); + sheet.Draw(spriteBatch, sprite, topRight + new Vector2(0, i), Params.RadiationBorderTint, origin, 0f, spriteScale); + } } isHovingOver = container.Contains(PlayerInput.MousePosition) && PlayerInput.MousePosition.X < topLeft.X + size.X; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs b/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs index 4e97d4929..bfb858f90 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Map/MapEntity.cs @@ -888,12 +888,12 @@ namespace Barotrauma var hsvBase = hsv; hsvBase.Y *= 4f; hsvBase.Z *= 0.8f; - btn.Color = ToolBox.HSVToRGB(hsvBase.X, hsvBase.Y, hsvBase.Z); - btn.SelectedColor = ToolBox.HSVToRGB(hsvBase.X, hsvBase.Y, hsvBase.Z); + btn.Color = ToolBoxCore.HSVToRGB(hsvBase.X, hsvBase.Y, hsvBase.Z); + btn.SelectedColor = ToolBoxCore.HSVToRGB(hsvBase.X, hsvBase.Y, hsvBase.Z); var hsvHover = hsv; hsvHover.Z *= 1.2f; - btn.HoverColor = ToolBox.HSVToRGB(hsvHover.X, hsvHover.Y, hsvHover.Z); + btn.HoverColor = ToolBoxCore.HSVToRGB(hsvHover.X, hsvHover.Y, hsvHover.Z); } public static List FilteredSelectedList { get; private set; } = new List(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ChildServerRelay.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ChildServerRelay.cs index 61bf8cec3..4107d249c 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ChildServerRelay.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ChildServerRelay.cs @@ -26,7 +26,9 @@ namespace Barotrauma.Networking PrivateStart(); - processInfo.Arguments += " -pipes " + writePipe.GetClientHandleAsString() + " " + readPipe.GetClientHandleAsString(); + processInfo.ArgumentList.Add("-pipes"); + processInfo.ArgumentList.Add(writePipe.GetClientHandleAsString()); + processInfo.ArgumentList.Add(readPipe.GetClientHandleAsString()); try { Process = Process.Start(processInfo); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs new file mode 100644 index 000000000..52a8d476c --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ConnectCommand.cs @@ -0,0 +1,135 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Barotrauma.Extensions; +using Barotrauma.Steam; + +namespace Barotrauma.Networking; + +readonly record struct ConnectCommand( + Option NameAndP2PEndpointsOption, + Option NameAndLidgrenEndpointOption, + Option SteamLobbyIdOption) +{ + public bool IsClientConnectedToEndpoint() + { + if (GameMain.Client?.ClientPeer == null) { return false; } + if (NameAndP2PEndpointsOption.TryUnwrap(out var nameAndP2PEndpoints)) + { + if (nameAndP2PEndpoints.Endpoints.Any(e => e.Equals(GameMain.Client.ClientPeer.ServerEndpoint))) { return true; } + } + if (NameAndLidgrenEndpointOption.TryUnwrap(out var nameAndLidgrenEndpoint)) + { + if (nameAndLidgrenEndpoint.Endpoint.Equals(GameMain.Client.ClientPeer.ServerEndpoint)) { return true; } + } + if (SteamLobbyIdOption.TryUnwrap(out var steamLobbyId)) + { + if (SteamManager.CurrentLobbyID == steamLobbyId.Value) { return true; } + } + return false; + } + + public readonly record struct NameAndP2PEndpoints( + string ServerName, + ImmutableArray Endpoints); + + public readonly record struct NameAndLidgrenEndpoint( + string ServerName, + LidgrenEndpoint Endpoint); + + public readonly record struct SteamLobbyId(ulong Value); + + public ConnectCommand(string serverName, Endpoint endpoint) + : this( + NameAndP2PEndpointsOption: endpoint is P2PEndpoint p2pEndpoint + ? Option.Some(new NameAndP2PEndpoints(ServerName: serverName, p2pEndpoint.ToEnumerable().ToImmutableArray())) + : Option.None, + NameAndLidgrenEndpointOption: endpoint is LidgrenEndpoint lidgrenEndpoint + ? Option.Some(new NameAndLidgrenEndpoint(ServerName: serverName, lidgrenEndpoint)) + : Option.None, + SteamLobbyIdOption: Option.None) { } + + public ConnectCommand(string serverName, ImmutableArray endpoints) + : this( + NameAndP2PEndpointsOption: Option.Some(new NameAndP2PEndpoints(ServerName: serverName, Endpoints: endpoints)), + NameAndLidgrenEndpointOption: Option.None, + SteamLobbyIdOption: Option.None) { } + + public ConnectCommand(string serverName, LidgrenEndpoint endpoint) + : this( + NameAndP2PEndpointsOption: Option.None, + NameAndLidgrenEndpointOption: Option.Some(new NameAndLidgrenEndpoint(ServerName: serverName, Endpoint: endpoint)), + SteamLobbyIdOption: Option.None) { } + + public ConnectCommand(SteamLobbyId lobbyId) + : this( + NameAndP2PEndpointsOption: Option.None, + NameAndLidgrenEndpointOption: Option.None, + SteamLobbyIdOption: Option.Some(lobbyId)) { } + + public static Option Parse(string str) + => Parse(ToolBox.SplitCommand(str)); + + public static Option Parse(IReadOnlyList? args) + { + if (args == null || args.Count < 2) { return Option.None; } + + if (args[0].Equals("-connect", StringComparison.OrdinalIgnoreCase)) + { + if (args.Count < 3) { return Option.None; } + + var serverName = args[1]; + + var endpointStrs = args[2].Split(","); + var endpoints = endpointStrs.Select(Endpoint.Parse).NotNone().ToImmutableArray(); + if (endpoints.Length != endpointStrs.Length) { return Option.None; } + + if (endpoints.All(e => e is P2PEndpoint)) + { + return Option.Some( + new ConnectCommand(serverName, endpoints.Cast().ToImmutableArray())); + } + else if (endpoints.Length == 1 && endpoints[0] is LidgrenEndpoint lidgrenEndpoint) + { + return Option.Some( + new ConnectCommand(serverName, lidgrenEndpoint)); + } + + return Option.None; + } + else if (args[0].Equals("+connect_lobby", StringComparison.OrdinalIgnoreCase)) + { + return UInt64.TryParse(args[1], out var lobbyId) + ? Option.Some(new ConnectCommand(new SteamLobbyId(lobbyId))) + : Option.None; + } + return Option.None; + } + + public override string ToString() + { + if (SteamLobbyIdOption.TryUnwrap(out var steamLobbyId)) + { + return $"+connect_lobby {steamLobbyId.Value}"; + } + + if (NameAndP2PEndpointsOption.TryUnwrap(out var nameAndP2PEndpoints)) + { + var escapedName = nameAndP2PEndpoints.ServerName.Replace("\"", "\\\""); + var escapedEndpoints = nameAndP2PEndpoints.Endpoints.Select(e => e.StringRepresentation).JoinEscaped(','); + return $"-connect \"{escapedName}\" {escapedEndpoints}"; + } + + if (NameAndLidgrenEndpointOption.TryUnwrap(out var nameAndLidgrenEndpoint)) + { + var escapedName = nameAndLidgrenEndpoint.ServerName.Replace("\"", "\\\""); + var endpoint = nameAndLidgrenEndpoint.Endpoint.StringRepresentation; + return $"-connect \"{escapedName}\" {endpoint}"; + } + + return ""; + } +} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs index c65b1da61..705f8d1b0 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/GameClient.cs @@ -162,7 +162,7 @@ namespace Barotrauma.Networking set; } - private readonly Endpoint serverEndpoint; + private readonly ImmutableArray serverEndpoints; private readonly Option ownerKey; public bool IsServerOwner => ownerKey.IsSome(); @@ -182,6 +182,9 @@ namespace Barotrauma.Networking public readonly NamedEvent OnPermissionChanged = new NamedEvent(); public GameClient(string newName, Endpoint endpoint, string serverName, Option ownerKey) + : this(newName, endpoint.ToEnumerable().ToImmutableArray(), serverName, ownerKey) { } + + public GameClient(string newName, ImmutableArray endpoints, string serverName, Option ownerKey) { //TODO: gui stuff should probably not be here? this.ownerKey = ownerKey; @@ -270,7 +273,7 @@ namespace Barotrauma.Networking ServerSettings = new ServerSettings(this, "Server", 0, 0, 0, false, false); Voting = new Voting(); - serverEndpoint = endpoint; + serverEndpoints = endpoints; InitiateServerJoin(serverName); //ServerLog = new ServerLog(""); @@ -281,7 +284,7 @@ namespace Barotrauma.Networking public ServerInfo CreateServerInfoFromSettings() { - var serverInfo = ServerInfo.FromServerConnection(ClientPeer.ServerConnection, ServerSettings); + var serverInfo = ServerInfo.FromServerEndpoints(ClientPeer.AllServerEndpoints, ServerSettings); GameMain.ServerListScreen.UpdateOrAddServerInfo(serverInfo); return serverInfo; } @@ -327,11 +330,14 @@ namespace Barotrauma.Networking ReadDataMessage, OnClientPeerDisconnect, OnConnectionInitializationComplete); - return serverEndpoint switch - { - LidgrenEndpoint lidgrenEndpoint => new LidgrenClientPeer(lidgrenEndpoint, callbacks, ownerKey), - SteamP2PEndpoint _ when ownerKey.TryUnwrap(out var key) => new SteamP2POwnerPeer(callbacks, key), - SteamP2PEndpoint steamP2PServerEndpoint when ownerKey.IsNone() => new SteamP2PClientPeer(steamP2PServerEndpoint, callbacks), + return serverEndpoints.First() switch + { + LidgrenEndpoint lidgrenEndpoint + => new LidgrenClientPeer(lidgrenEndpoint, callbacks, ownerKey), + P2PEndpoint when ownerKey.TryUnwrap(out int key) + => new P2POwnerPeer(callbacks, key, serverEndpoints.Cast().ToImmutableArray()), + P2PEndpoint when ownerKey.IsNone() + => new P2PClientPeer(serverEndpoints.Cast().ToImmutableArray(), callbacks), _ => throw new ArgumentOutOfRangeException() }; } @@ -800,6 +806,9 @@ namespace Barotrauma.Networking case ServerPacketHeader.ACHIEVEMENT: ReadAchievement(inc); break; + case ServerPacketHeader.ACHIEVEMENT_STAT: + ReadAchievementStat(inc); + break; case ServerPacketHeader.CHEATS_ENABLED: bool cheatsEnabled = inc.ReadBoolean(); inc.ReadPadBits(); @@ -810,7 +819,7 @@ namespace Barotrauma.Networking else { DebugConsole.CheatsEnabled = cheatsEnabled; - SteamAchievementManager.CheatsEnabled = cheatsEnabled; + AchievementManager.CheatsEnabled = cheatsEnabled; if (cheatsEnabled) { var cheatMessageBox = new GUIMessageBox(TextManager.Get("CheatsEnabledTitle"), TextManager.Get("CheatsEnabledDescription")); @@ -858,7 +867,7 @@ namespace Barotrauma.Networking private void ReadStartGameFinalize(IReadMessage inc) { - TaskPool.ListTasks(); + TaskPool.ListTasks(DebugConsole.Log); ushort contentToPreloadCount = inc.ReadUInt16(); List contentToPreload = new List(); for (int i = 0; i < contentToPreloadCount; i++) @@ -995,8 +1004,9 @@ namespace Barotrauma.Networking } else { - if (ClientPeer is SteamP2PClientPeer or SteamP2POwnerPeer) + if (ClientPeer is P2PClientPeer or P2POwnerPeer) { + Eos.EosSessionManager.LeaveSession(); SteamManager.LeaveLobby(); } @@ -1005,10 +1015,7 @@ namespace Barotrauma.Networking GameMain.GameSession?.Campaign?.CancelStartRound(); - if (SteamManager.IsInitialized) - { - Steamworks.SteamFriends.ClearRichPresence(); - } + UpdatePresence(""); foreach (var fileTransfer in FileReceiver.ActiveTransfers.ToArray()) { FileReceiver.StopTransfer(fileTransfer, deleteFile: true); @@ -1097,19 +1104,47 @@ namespace Barotrauma.Networking ClientPeer.ServerContentPackages = prevContentPackages; } } - - private void OnConnectionInitializationComplete() + + private void UpdatePresence(string connectCommand) { + #warning TODO: use store localization functionality + var desc = TextManager.GetWithVariable("FriendPlayingOnServer", "[servername]", ServerName); + + async Task updateEosPresence() + { + var epicIds = EosInterface.IdQueries.GetLoggedInEpicIds(); + if (!epicIds.FirstOrNone().TryUnwrap(out var epicAccountId)) { return; } + + var setPresenceResult = await EosInterface.Presence.SetJoinCommand( + epicAccountId: epicAccountId, + desc: desc.Value, + serverName: ServerName, + joinCommand: connectCommand); + DebugConsole.NewMessage($"Set connect command: {connectCommand}, result: {setPresenceResult}"); + } + + TaskPool.Add( + "UpdateEosPresence", + updateEosPresence(), + static _ => { }); + if (SteamManager.IsInitialized) { Steamworks.SteamFriends.ClearRichPresence(); - Steamworks.SteamFriends.SetRichPresence("servername", ServerName); - #warning TODO: use Steamworks localization functionality - Steamworks.SteamFriends.SetRichPresence("status", - TextManager.GetWithVariable("FriendPlayingOnServer", "[servername]", ServerName).Value); - Steamworks.SteamFriends.SetRichPresence("connect", - $"-connect \"{ToolBox.EscapeCharacters(ServerName)}\" {serverEndpoint}"); + if (!connectCommand.IsNullOrWhiteSpace()) + { + Steamworks.SteamFriends.SetRichPresence("servername", ServerName); + Steamworks.SteamFriends.SetRichPresence("status", + desc.Value); + Steamworks.SteamFriends.SetRichPresence("connect", + connectCommand); + } } + } + + private void OnConnectionInitializationComplete() + { + UpdatePresence($"-connect \"{ToolBox.EscapeCharacters(ServerName)}\" {string.Join(",", serverEndpoints.Select(e => e.StringRepresentation))}"); canStart = true; connected = true; @@ -1167,18 +1202,16 @@ namespace Barotrauma.Networking } - private void ReadAchievement(IReadMessage inc) + private static void ReadAchievement(IReadMessage inc) { Identifier achievementIdentifier = inc.ReadIdentifier(); - int amount = inc.ReadInt32(); - if (amount == 0) - { - SteamAchievementManager.UnlockAchievement(achievementIdentifier); - } - else - { - SteamAchievementManager.IncrementStat(achievementIdentifier, amount); - } + AchievementManager.UnlockAchievement(achievementIdentifier); + } + + private static void ReadAchievementStat(IReadMessage inc) + { + var netStat = INetSerializableStruct.Read(inc); + AchievementManager.IncrementStat(netStat.Stat, netStat.Amount); } private static void ReadCircuitBoxMessage(IReadMessage inc) @@ -1885,8 +1918,9 @@ namespace Barotrauma.Networking } if (updateClientListId) { LastClientListUpdateID = listId; } - if (ClientPeer is SteamP2POwnerPeer) + if (ClientPeer is P2POwnerPeer) { + Eos.EosSessionManager.UpdateOwnedSession(ClientPeer.ServerConnection.Endpoint, ServerSettings); TaskPool.Add("WaitForPingDataAsync (owner)", Steamworks.SteamNetworkingUtils.WaitForPingDataAsync(), (task) => { @@ -2029,8 +2063,9 @@ namespace Barotrauma.Networking ServerSettings.AllowSubVoting = allowSubVoting; ServerSettings.AllowModeVoting = allowModeVoting; - if (ClientPeer is SteamP2POwnerPeer) + if (ClientPeer is P2POwnerPeer) { + Eos.EosSessionManager.UpdateOwnedSession(ClientPeer.ServerConnection.Endpoint, ServerSettings); Steam.SteamManager.UpdateLobby(ServerSettings); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/DualStackP2PSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/DualStackP2PSocket.cs new file mode 100644 index 000000000..613767851 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/DualStackP2PSocket.cs @@ -0,0 +1,82 @@ +#nullable enable + +namespace Barotrauma.Networking; + +sealed class DualStackP2PSocket : P2PSocket +{ + private readonly Option eosSocket; + private readonly Option steamSocket; + + private DualStackP2PSocket( + Callbacks callbacks, + Option eosSocket, + Option steamSocket) : + base(callbacks) + { + this.eosSocket = eosSocket; + this.steamSocket = steamSocket; + } + + public static Result Create(Callbacks callbacks) + { + var eosP2PSocketResult = EosP2PSocket.Create(callbacks); + var steamP2PSocketResult = SteamListenSocket.Create(callbacks); + if (eosP2PSocketResult.TryUnwrapFailure(out var eosError) + && steamP2PSocketResult.TryUnwrapFailure(out var steamError)) + { + return Result.Failure(new Error(eosError, steamError)); + } + return Result.Success((P2PSocket)new DualStackP2PSocket( + callbacks, + eosP2PSocketResult.TryUnwrapSuccess(out var eosP2PSocket) + ? Option.Some((EosP2PSocket)eosP2PSocket) + : Option.None, + steamP2PSocketResult.TryUnwrapSuccess(out var steamP2PSocket) + ? Option.Some((SteamListenSocket)steamP2PSocket) + : Option.None)); + } + + public override void ProcessIncomingMessages() + { + if (eosSocket.TryUnwrap(out var eosP2PSocket)) { eosP2PSocket.ProcessIncomingMessages(); } + if (steamSocket.TryUnwrap(out var steamP2PSocket)) { steamP2PSocket.ProcessIncomingMessages(); } + } + + public override bool SendMessage(P2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod) + { + return endpoint switch + { + EosP2PEndpoint eosP2PEndpoint when eosSocket.TryUnwrap(out var eosP2PSocket) + => eosP2PSocket.SendMessage(eosP2PEndpoint, outMsg, deliveryMethod), + SteamP2PEndpoint steamP2PEndpoint when steamSocket.TryUnwrap(out var steamP2PSocket) + => steamP2PSocket.SendMessage(steamP2PEndpoint, outMsg, deliveryMethod), + _ + => false + }; + } + + public override void CloseConnection(P2PEndpoint endpoint) + { + switch (endpoint) + { + case EosP2PEndpoint eosP2PEndpoint: + if (eosSocket.TryUnwrap(out var eosP2PSocket)) + { + eosP2PSocket.CloseConnection(eosP2PEndpoint); + } + break; + case SteamP2PEndpoint steamP2PEndpoint: + if (steamSocket.TryUnwrap(out var steamP2PSocket)) + { + steamP2PSocket.CloseConnection(steamP2PEndpoint); + } + break; + } + } + + public override void Dispose() + { + if (eosSocket.TryUnwrap(out var eosP2PSocket)) { eosP2PSocket.Dispose(); } + if (steamSocket.TryUnwrap(out var steamP2PSocket)) { steamP2PSocket.Dispose(); } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/EosP2PSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/EosP2PSocket.cs new file mode 100644 index 000000000..73531529b --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/EosP2PSocket.cs @@ -0,0 +1,99 @@ +#nullable enable + +namespace Barotrauma.Networking; + +sealed class EosP2PSocket : P2PSocket +{ + private readonly EosInterface.P2PSocket eosSocket; + + private EosP2PSocket( + Callbacks callbacks, + EosInterface.P2PSocket eosSocket) + : base(callbacks) + { + this.eosSocket = eosSocket; + } + + public static Result Create(Callbacks callbacks) + { + if (!EosInterface.Core.IsInitialized) { return Result.Failure(new Error(ErrorCode.EosNotInitialized)); } + + var eosSocketId = new EosInterface.SocketId { SocketName = EosP2PEndpoint.SocketName }; + if (EosInterface.IdQueries.GetLoggedInPuids() is not { Length: > 0 } puids) + { + return Result.Failure(new Error(ErrorCode.EosNotLoggedIn)); + } + var socketCreateResult = EosInterface.P2PSocket.Create(puids[0], eosSocketId); + + if (!socketCreateResult.TryUnwrapSuccess(out var eosSocket)) { return Result.Failure(new Error(ErrorCode.FailedToCreateEosP2PSocket, socketCreateResult.ToString())); } + var retVal = new EosP2PSocket(callbacks, eosSocket); + + eosSocket.HandleIncomingConnection.Register("Event".ToIdentifier(), retVal.OnIncomingConnection); + eosSocket.HandleClosedConnection.Register("Event".ToIdentifier(), retVal.OnConnectionClosed); + + return Result.Success((P2PSocket)retVal); + } + + public override void ProcessIncomingMessages() + { + foreach (var msg in eosSocket.GetMessageBatch()) + { + callbacks.OnData(new EosP2PEndpoint(msg.Sender), new ReadWriteMessage(msg.Buffer, 0, msg.ByteLength * 8, false)); + } + } + + public override bool SendMessage(P2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod) + { + if (endpoint is not EosP2PEndpoint { ProductUserId: var puid }) { return false; } + var sendResult = eosSocket.SendMessage(new EosInterface.P2PSocket.OutgoingMessage( + Buffer: outMsg.Buffer, + ByteLength: outMsg.LengthBytes, + Destination: puid, + DeliveryMethod: deliveryMethod)); + return sendResult.IsSuccess; + } + + private void OnIncomingConnection(EosInterface.P2PSocket.IncomingConnectionRequest request) + { + var remoteEndpoint = new EosP2PEndpoint(request.RemoteUserId); + + if (callbacks.OnIncomingConnection(remoteEndpoint)) + { + request.Accept(); + } + } + + private void OnConnectionClosed(EosInterface.P2PSocket.RemoteConnectionClosed data) + { + var remoteEndpoint = new EosP2PEndpoint(data.RemoteUserId); + + var peerDisconnectPacket = PeerDisconnectPacket.WithReason(data.Reason switch + { + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.Unknown => DisconnectReason.Unknown, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.ClosedByLocalUser => DisconnectReason.Disconnected, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.ClosedByPeer => DisconnectReason.Disconnected, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.TimedOut => DisconnectReason.Timeout, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.TooManyConnections => DisconnectReason.ServerFull, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.InvalidMessage => DisconnectReason.Unknown, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.InvalidData => DisconnectReason.Unknown, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.ConnectionFailed => DisconnectReason.AuthenticationFailed, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.ConnectionClosed => DisconnectReason.Disconnected, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.NegotiationFailed => DisconnectReason.AuthenticationFailed, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.UnexpectedError => DisconnectReason.Unknown, + EosInterface.P2PSocket.RemoteConnectionClosed.ConnectionClosedReason.Unhandled => DisconnectReason.Unknown, + _ => DisconnectReason.Unknown + }); + callbacks.OnConnectionClosed(remoteEndpoint, peerDisconnectPacket); + } + + public override void CloseConnection(P2PEndpoint endpoint) + { + if (endpoint is not EosP2PEndpoint { ProductUserId: var puid }) { return; } + eosSocket.CloseConnection(puid); + } + + public override void Dispose() + { + eosSocket.Dispose(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/P2PSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/P2PSocket.cs new file mode 100644 index 000000000..4c54b406d --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/P2PSocket.cs @@ -0,0 +1,56 @@ +#nullable enable +using System; +using System.Collections.Immutable; +using System.Linq; +using Barotrauma.Extensions; + +namespace Barotrauma.Networking; + +abstract class P2PSocket : IDisposable +{ + public enum ErrorCode + { + EosNotInitialized, + EosNotLoggedIn, + FailedToCreateEosP2PSocket, + + SteamNotInitialized, + FailedToCreateSteamP2PSocket + } + + public readonly record struct Error( + ImmutableArray<(ErrorCode Code, string AdditionalInfo)> CodesAndInfo) + { + public Error(ErrorCode code, string? additionalInfo = "") : this((code, additionalInfo ?? "").ToEnumerable().ToImmutableArray()) { } + public Error(params Error[] innerErrors) : this(innerErrors.SelectMany(ie => ie.CodesAndInfo).ToImmutableArray()) { } + + public override string? ToString() + { + if (CodesAndInfo.IsDefault) + { + return "default(Error)"; + } + + return $"Errors({string.Join("; ", CodesAndInfo)})"; + } + } + + public readonly record struct Callbacks( + Predicate OnIncomingConnection, + Action OnConnectionClosed, + Action OnData); + protected readonly Callbacks callbacks; + + protected P2PSocket(Callbacks callbacks) + { + this.callbacks = callbacks; + } + + public abstract void ProcessIncomingMessages(); + + public abstract bool SendMessage(P2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod); + + public abstract void CloseConnection(P2PEndpoint endpoint); + + public abstract void Dispose(); +} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs new file mode 100644 index 000000000..47e3b69e5 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamConnectSocket.cs @@ -0,0 +1,114 @@ +using System; +using System.Runtime.InteropServices; +using Barotrauma.Steam; +namespace Barotrauma.Networking; + +sealed class SteamConnectSocket : P2PSocket +{ + private sealed class ConnectionManager : Steamworks.ConnectionManager, Steamworks.IConnectionManager + { + private SteamP2PEndpoint endpoint; + private Callbacks callbacks; + public void SetEndpointAndCallbacks(SteamP2PEndpoint endpoint, Callbacks callbacks) + { + this.endpoint = endpoint; + this.callbacks = callbacks; + } + + public override void OnMessage(IntPtr data, int size, long messageNum, long recvTime, int channel) + { + var dataArray = new byte[size]; + Marshal.Copy(source: data, destination: dataArray, startIndex: 0, length: size); + + callbacks.OnData(endpoint, new ReadWriteMessage(dataArray, bitPos: 0, lBits: size * 8, copyBuf: false)); + } + + public override void OnDisconnected(Steamworks.Data.ConnectionInfo info) + { + if (!info.Identity.IsSteamId) { return; } + var remoteEndpoint = new SteamP2PEndpoint(new SteamId((Steamworks.SteamId)info.Identity)); + var peerDisconnectPacket = PeerDisconnectPacket.WithReason(info.EndReason switch + { + Steamworks.NetConnectionEnd.App_Generic => DisconnectReason.Disconnected, + Steamworks.NetConnectionEnd.AppException_Generic => DisconnectReason.Unknown, + + Steamworks.NetConnectionEnd.Local_OfflineMode => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_ManyRelayConnectivity => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_HostedServerPrimaryRelay => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_NetworkConfig => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_Rights => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_P2P_ICE_NoPublicAddresses => DisconnectReason.SteamP2PError, + + Steamworks.NetConnectionEnd.Remote_Timeout => DisconnectReason.SteamP2PTimeOut, + Steamworks.NetConnectionEnd.Remote_BadCrypt => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_BadCert => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_BadProtocolVersion => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_P2P_ICE_NoPublicAddresses => DisconnectReason.SteamP2PError, + + Steamworks.NetConnectionEnd.Misc_Generic => DisconnectReason.Unknown, + Steamworks.NetConnectionEnd.Misc_InternalError => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_Timeout => DisconnectReason.SteamP2PTimeOut, + Steamworks.NetConnectionEnd.Misc_SteamConnectivity => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_NoRelaySessionsToClient => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_P2P_Rendezvous => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_P2P_NAT_Firewall => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_PeerSentNoConnection => DisconnectReason.SteamP2PError, + + _ => DisconnectReason.Unknown + }); + callbacks.OnConnectionClosed(remoteEndpoint, peerDisconnectPacket); + base.OnDisconnected(info); + } + } + + private readonly SteamP2PEndpoint expectedEndpoint; + private readonly ConnectionManager connectionManager; + + private SteamConnectSocket(SteamP2PEndpoint expectedEndpoint, Callbacks callbacks, ConnectionManager connectionManager) : base(callbacks) + { + this.expectedEndpoint = expectedEndpoint; + this.connectionManager = connectionManager; + } + + public static Result Create(SteamP2PEndpoint endpoint, Callbacks callbacks) + { + if (!SteamManager.IsInitialized) { return Result.Failure(new Error(ErrorCode.SteamNotInitialized)); } + + var connectionManager = Steamworks.SteamNetworkingSockets.ConnectRelay(endpoint.SteamId.Value); + if (connectionManager is null) { return Result.Failure(new Error(ErrorCode.FailedToCreateSteamP2PSocket)); } + connectionManager.SetEndpointAndCallbacks(endpoint, callbacks); + + return Result.Success((P2PSocket)new SteamConnectSocket(endpoint, callbacks, connectionManager)); + } + + public override void ProcessIncomingMessages() + { + connectionManager.Receive(); + } + + public override bool SendMessage(P2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod) + { + if (endpoint != expectedEndpoint) { return false; } + var result = connectionManager.Connection.SendMessage( + data: outMsg.Buffer, + offset: 0, + length: outMsg.LengthBytes, + sendType: deliveryMethod switch + { + DeliveryMethod.Reliable => Steamworks.Data.SendType.Reliable, + _ => Steamworks.Data.SendType.Unreliable + }); + return result == Steamworks.Result.OK; + } + + public override void CloseConnection(P2PEndpoint endpoint) + { + if (endpoint != expectedEndpoint) { return; } + connectionManager.Close(); + } + + public override void Dispose() + { + connectionManager.Close(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs new file mode 100644 index 000000000..b1e7c8170 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/P2PSocket/SteamListenSocket.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Barotrauma.Steam; + +namespace Barotrauma.Networking; + +sealed class SteamListenSocket : P2PSocket +{ + private sealed class SocketManager : Steamworks.SocketManager, Steamworks.ISocketManager + { + private Callbacks callbacks; + private readonly Dictionary endpointToConnection = new(); + + public void SetCallbacks(Callbacks callbacks) + { + this.callbacks = callbacks; + } + + public override void OnConnecting(Steamworks.Data.Connection connection, Steamworks.Data.ConnectionInfo info) + { + if (!info.Identity.IsSteamId) { return; } + var remoteEndpoint = new SteamP2PEndpoint(new SteamId((Steamworks.SteamId)info.Identity)); + endpointToConnection[remoteEndpoint] = connection; + if (callbacks.OnIncomingConnection(remoteEndpoint)) + { + connection.Accept(); + } + } + + public override void OnDisconnected(Steamworks.Data.Connection connection, Steamworks.Data.ConnectionInfo info) + { + if (!info.Identity.IsSteamId) { return; } + var remoteEndpoint = new SteamP2PEndpoint(new SteamId((Steamworks.SteamId)info.Identity)); + endpointToConnection.Remove(remoteEndpoint); + var peerDisconnectPacket = PeerDisconnectPacket.WithReason(info.EndReason switch + { + Steamworks.NetConnectionEnd.App_Generic => DisconnectReason.Disconnected, + Steamworks.NetConnectionEnd.AppException_Generic => DisconnectReason.Unknown, + + Steamworks.NetConnectionEnd.Local_OfflineMode => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_ManyRelayConnectivity => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_HostedServerPrimaryRelay => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_NetworkConfig => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_Rights => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Local_P2P_ICE_NoPublicAddresses => DisconnectReason.SteamP2PError, + + Steamworks.NetConnectionEnd.Remote_Timeout => DisconnectReason.SteamP2PTimeOut, + Steamworks.NetConnectionEnd.Remote_BadCrypt => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_BadCert => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_BadProtocolVersion => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Remote_P2P_ICE_NoPublicAddresses => DisconnectReason.SteamP2PError, + + Steamworks.NetConnectionEnd.Misc_Generic => DisconnectReason.Unknown, + Steamworks.NetConnectionEnd.Misc_InternalError => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_Timeout => DisconnectReason.SteamP2PTimeOut, + Steamworks.NetConnectionEnd.Misc_SteamConnectivity => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_NoRelaySessionsToClient => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_P2P_Rendezvous => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_P2P_NAT_Firewall => DisconnectReason.SteamP2PError, + Steamworks.NetConnectionEnd.Misc_PeerSentNoConnection => DisconnectReason.SteamP2PError, + + _ => DisconnectReason.Unknown + }); + callbacks.OnConnectionClosed(remoteEndpoint, peerDisconnectPacket); + base.OnDisconnected(connection, info); + } + + public override void OnMessage(Steamworks.Data.Connection connection, Steamworks.Data.NetIdentity identity, IntPtr data, int size, long messageNum, long recvTime, int channel) + { + if (!identity.IsSteamId) { return; } + var endpoint = new SteamP2PEndpoint(new SteamId((Steamworks.SteamId)identity)); + + var dataArray = new byte[size]; + Marshal.Copy(source: data, destination: dataArray, startIndex: 0, length: size); + + callbacks.OnData(endpoint, new ReadWriteMessage(dataArray, bitPos: 0, lBits: size * 8, copyBuf: false)); + } + + internal bool SendMessage(SteamP2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod) + { + if (!endpointToConnection.TryGetValue(endpoint, out var connection)) + { + return false; + } + + var result = connection.SendMessage( + data: outMsg.Buffer, + offset: 0, + length: outMsg.LengthBytes, + sendType: deliveryMethod switch + { + DeliveryMethod.Reliable => Steamworks.Data.SendType.Reliable, + _ => Steamworks.Data.SendType.Unreliable + }); + return result == Steamworks.Result.OK; + } + + internal void CloseConnection(SteamP2PEndpoint endpoint) + { + if (!endpointToConnection.TryGetValue(endpoint, out var connection)) { return; } + connection.Close(); + } + } + + private readonly SocketManager socketManager; + + private SteamListenSocket( + Callbacks callbacks, + SocketManager socketManager) + : base(callbacks) + { + this.socketManager = socketManager; + } + + public static Result Create(Callbacks callbacks) + { + if (!SteamManager.IsInitialized) { return Result.Failure(new Error(ErrorCode.SteamNotInitialized)); } + + var socketManager = Steamworks.SteamNetworkingSockets.CreateRelaySocket(); + if (socketManager is null) { return Result.Failure(new Error(ErrorCode.FailedToCreateSteamP2PSocket)); } + socketManager.SetCallbacks(callbacks); + + return Result.Success((P2PSocket)new SteamListenSocket(callbacks, socketManager)); + } + + public override void ProcessIncomingMessages() + { + socketManager.Receive(); + } + + public override bool SendMessage(P2PEndpoint endpoint, IWriteMessage outMsg, DeliveryMethod deliveryMethod) + { + if (endpoint is not SteamP2PEndpoint steamP2PEndpoint) { return false; } + return socketManager.SendMessage(steamP2PEndpoint, outMsg, deliveryMethod); + } + + public override void CloseConnection(P2PEndpoint endpoint) + { + if (endpoint is not SteamP2PEndpoint steamP2PEndpoint) { return; } + socketManager.CloseConnection(steamP2PEndpoint); + } + + public override void Dispose() + { + socketManager.Close(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs index 6718bcbf4..ef0b76332 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/ClientPeer.cs @@ -1,12 +1,18 @@ #nullable enable -using Barotrauma.Steam; -using System; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; using Microsoft.Xna.Framework; namespace Barotrauma.Networking { + internal abstract class ClientPeer : ClientPeer where TEndpoint : Endpoint + { + public new TEndpoint ServerEndpoint => (base.ServerEndpoint as TEndpoint)!; + protected ClientPeer(TEndpoint serverEndpoint, ImmutableArray allServerEndpoints, Callbacks callbacks, Option ownerKey) + : base(serverEndpoint, allServerEndpoints, callbacks, ownerKey) { } + } + internal abstract class ClientPeer { public ImmutableArray ServerContentPackages { get; set; } = @@ -25,21 +31,22 @@ namespace Barotrauma.Networking protected readonly Callbacks callbacks; public readonly Endpoint ServerEndpoint; + public readonly ImmutableArray AllServerEndpoints; public NetworkConnection? ServerConnection { get; protected set; } - protected readonly bool isOwner; + protected bool IsOwner => ownerKey.IsSome(); protected readonly Option ownerKey; public bool IsActive => isActive; protected bool isActive; - public ClientPeer(Endpoint serverEndpoint, Callbacks callbacks, Option ownerKey) + protected ClientPeer(Endpoint serverEndpoint, ImmutableArray allServerEndpoints, Callbacks callbacks, Option ownerKey) { ServerEndpoint = serverEndpoint; + AllServerEndpoints = allServerEndpoints; this.callbacks = callbacks; this.ownerKey = ownerKey; - isOwner = ownerKey.IsSome(); } public abstract void Start(); @@ -53,7 +60,7 @@ namespace Barotrauma.Networking protected ConnectionInitialization initializationStep; public bool ContentPackageOrderReceived { get; set; } protected int passwordSalt; - protected Option steamAuthTicket; + protected Option authTicket; private GUIMessageBox? passwordMsgBox; public bool WaitingForPassword @@ -67,43 +74,64 @@ namespace Barotrauma.Networking public IReadMessage Message; } + protected abstract Task> GetAccountId(); + + protected void OnInitializationComplete() + { + passwordMsgBox?.Close(); + if (initializationStep == ConnectionInitialization.Success) { return; } + + callbacks.OnInitializationComplete.Invoke(); + initializationStep = ConnectionInitialization.Success; + } + protected void ReadConnectionInitializationStep(IncomingInitializationMessage inc) { + if (inc.InitializationStep != ConnectionInitialization.Password) + { + passwordMsgBox?.Close(); + } + switch (inc.InitializationStep) { - case ConnectionInitialization.SteamTicketAndVersion: + case ConnectionInitialization.AuthInfoAndVersion: { - if (initializationStep != ConnectionInitialization.SteamTicketAndVersion) { return; } + if (initializationStep != ConnectionInitialization.AuthInfoAndVersion) { return; } - PeerPacketHeaders headers = new PeerPacketHeaders + TaskPool.Add($"{GetType().Name}.{nameof(GetAccountId)}", GetAccountId(), t => { - DeliveryMethod = DeliveryMethod.Reliable, - PacketHeader = PacketHeader.IsConnectionInitializationStep, - Initialization = ConnectionInitialization.SteamTicketAndVersion - }; + if (GameMain.Client?.ClientPeer is null) { return; } + + if (!t.TryGetResult(out Option accountId)) + { + Close(PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + } + + var headers = new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsConnectionInitializationStep, + Initialization = ConnectionInitialization.AuthInfoAndVersion + }; - if (steamAuthTicket.TryUnwrap(out var authTicket) && authTicket is { Canceled: true }) - { - throw new InvalidOperationException("ReadConnectionInitializationStep failed: Steam auth ticket has been cancelled."); - } + var body = new ClientAuthTicketAndVersionPacket + { + Name = GameMain.Client.Name, + OwnerKey = ownerKey, + AccountId = accountId, + AuthTicket = authTicket, + GameVersion = GameMain.Version.ToString(), + Language = GameSettings.CurrentConfig.Language.Value + }; - ClientSteamTicketAndVersionPacket body = new ClientSteamTicketAndVersionPacket - { - Name = GameMain.Client.Name, - OwnerKey = ownerKey, - SteamId = SteamManager.GetSteamId().Select(id => (AccountId)id), - SteamAuthTicket = steamAuthTicket.Bind(t => t.Data != null ? Option.Some(t.Data) : Option.None), - GameVersion = GameMain.Version.ToString(), - Language = GameSettings.CurrentConfig.Language.Value - }; - - SendMsgInternal(headers, body); + SendMsgInternal(headers, body); + }); break; } case ConnectionInitialization.ContentPackageOrder: { if (initializationStep - is ConnectionInitialization.SteamTicketAndVersion + is ConnectionInitialization.AuthInfoAndVersion or ConnectionInitialization.Password) { initializationStep = ConnectionInitialization.ContentPackageOrder; @@ -136,7 +164,7 @@ namespace Barotrauma.Networking break; } case ConnectionInitialization.Password: - if (initializationStep == ConnectionInitialization.SteamTicketAndVersion) + if (initializationStep == ConnectionInitialization.AuthInfoAndVersion) { initializationStep = ConnectionInitialization.Password; } @@ -152,6 +180,7 @@ namespace Barotrauma.Networking LocalizedString pwMsg = TextManager.Get("PasswordRequired"); + passwordMsgBox?.Close(); passwordMsgBox = new GUIMessageBox(pwMsg, "", new LocalizedString[] { TextManager.Get("OK"), TextManager.Get("Cancel") }, relativeSize: new Vector2(0.25f, 0.1f), minSize: new Point(400, GUI.IntScale(170))); var passwordHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.5f), passwordMsgBox.Content.RectTransform), childAnchor: Anchor.TopCenter); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs index 1ed7a4e53..a2a1b7120 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/LidgrenClientPeer.cs @@ -1,26 +1,25 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Text; +using System.Threading.Tasks; +using Barotrauma.Extensions; using Lidgren.Network; using Barotrauma.Steam; using System.Net.Sockets; namespace Barotrauma.Networking { - internal sealed class LidgrenClientPeer : ClientPeer + internal sealed class LidgrenClientPeer : ClientPeer { private NetClient? netClient; private readonly NetPeerConfiguration netPeerConfiguration; private readonly List incomingLidgrenMessages; - private LidgrenEndpoint lidgrenEndpoint => - ServerConnection is LidgrenConnection { Endpoint: LidgrenEndpoint result } - ? result - : throw new InvalidOperationException(); - - public LidgrenClientPeer(LidgrenEndpoint endpoint, Callbacks callbacks, Option ownerKey) : base(endpoint, callbacks, ownerKey) + public LidgrenClientPeer(LidgrenEndpoint endpoint, Callbacks callbacks, Option ownerKey) : base(endpoint, ((Endpoint)endpoint).ToEnumerable().ToImmutableArray(), callbacks, ownerKey) { ServerConnection = null; @@ -56,18 +55,9 @@ namespace Barotrauma.Networking netClient = new NetClient(netPeerConfiguration); - if (SteamManager.IsInitialized) - { - steamAuthTicket = SteamManager.GetAuthSessionTicketForMultiplayer(ServerEndpoint); - if (steamAuthTicket.IsNone()) - { - throw new Exception("GetAuthSessionTicket returned null"); - } - } + initializationStep = ConnectionInitialization.AuthInfoAndVersion; - initializationStep = ConnectionInitialization.SteamTicketAndVersion; - - if (!(ServerEndpoint is LidgrenEndpoint lidgrenEndpointValue)) + if (ServerEndpoint is not { } lidgrenEndpointValue) { throw new InvalidCastException($"Endpoint is not {nameof(LidgrenEndpoint)}"); } @@ -79,12 +69,25 @@ namespace Barotrauma.Networking netClient.Start(); - var netConnection = netClient.Connect(lidgrenEndpointValue.NetEndpoint); + TaskPool.Add( + $"{nameof(LidgrenClientPeer)}.GetAuthTicket", + AuthenticationTicket.Create(ServerEndpoint), + t => + { + if (!t.TryGetResult(out Option authenticationTicket)) + { + Close(PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + return; + } + authTicket = authenticationTicket; - ServerConnection = new LidgrenConnection(netConnection) - { - Status = NetworkConnectionStatus.Connected - }; + var netConnection = netClient.Connect(lidgrenEndpointValue.NetEndpoint); + + ServerConnection = new LidgrenConnection(netConnection) + { + Status = NetworkConnectionStatus.Connected + }; + }); isActive = true; } @@ -96,7 +99,7 @@ namespace Barotrauma.Networking ToolBox.ThrowIfNull(netClient); ToolBox.ThrowIfNull(incomingLidgrenMessages); - if (isOwner && !ChildServerRelay.IsProcessAlive) + if (IsOwner && !ChildServerRelay.IsProcessAlive) { var gameClient = GameMain.Client; Close(PeerDisconnectPacket.WithReason(DisconnectReason.ServerCrashed)); @@ -112,9 +115,11 @@ namespace Barotrauma.Networking foreach (NetIncomingMessage inc in incomingLidgrenMessages) { - if (!inc.SenderConnection.RemoteEndPoint.EquivalentTo(lidgrenEndpoint.NetEndpoint)) + var remoteEndpoint = new LidgrenEndpoint(inc.SenderEndPoint); + + if (remoteEndpoint != ServerEndpoint) { - DebugConsole.AddWarning($"Mismatched endpoint: expected {lidgrenEndpoint.NetEndpoint}, got {inc.SenderConnection.RemoteEndPoint}"); + DebugConsole.AddWarning($"Mismatched endpoint: expected {ServerEndpoint.NetEndpoint}, got {inc.SenderConnection.RemoteEndPoint}"); continue; } @@ -152,11 +157,7 @@ namespace Barotrauma.Networking } else { - if (initializationStep != ConnectionInitialization.Success) - { - callbacks.OnInitializationComplete.Invoke(); - initializationStep = ConnectionInitialization.Success; - } + OnInitializationComplete(); var packet = INetSerializableStruct.Read(inc); callbacks.OnMessageReceived.Invoke(packet.GetReadMessage(packetHeader.IsCompressed(), ServerConnection)); @@ -212,9 +213,6 @@ namespace Barotrauma.Networking netClient.Shutdown(peerDisconnectPacket.ToLidgrenStringRepresentation()); netClient = null; - if (steamAuthTicket.TryUnwrap(out var ticket)) { ticket.Cancel(); } - steamAuthTicket = Option.None; - callbacks.OnDisconnect.Invoke(peerDisconnectPacket); } @@ -270,7 +268,21 @@ namespace Barotrauma.Networking return netClient.SendMessage(msg.ToLidgren(netClient), deliveryMethod.ToLidgren()); } + protected override async Task> GetAccountId() + { + if (!EosInterface.Core.IsInitialized) { return SteamManager.GetSteamId().Select(id => (AccountId)id); } + + var selfPuids = EosInterface.IdQueries.GetLoggedInPuids(); + if (selfPuids.None()) { return Option.None; } + var accountIdsResult = await EosInterface.IdQueries.GetSelfExternalAccountIds(selfPuids.First()); + return accountIdsResult.TryUnwrapSuccess(out var accountIds) && accountIds.Length > 0 + ? Option.Some(accountIds[0]) + : Option.None; + } + #if DEBUG + + public override void ForceTimeOut() { netClient?.ServerConnection?.ForceTimeOut(); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2PClientPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2PClientPeer.cs similarity index 59% rename from Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2PClientPeer.cs rename to Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2PClientPeer.cs index 08318d13f..d6624b4cf 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2PClientPeer.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2PClientPeer.cs @@ -1,136 +1,142 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Text; -using Barotrauma.Steam; using System.Threading; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Steam; namespace Barotrauma.Networking { - internal sealed class SteamP2PClientPeer : ClientPeer + internal sealed class P2PClientPeer : ClientPeer { - private readonly SteamId hostSteamId; private double timeout; private double heartbeatTimer; - private double connectionStatusTimer; private long sentBytes, receivedBytes; private readonly List incomingInitializationMessages = new List(); private readonly List incomingDataMessages = new List(); + private readonly MessageFragmenter fragmenter = new(); + private readonly MessageDefragmenter defragmenter = new(); - public SteamP2PClientPeer(SteamP2PEndpoint endpoint, Callbacks callbacks) : base(endpoint, callbacks, Option.None()) + private P2PSocket? socket; + + private static P2PEndpoint GetPrimaryEndpoint(ImmutableArray allEndpoints) + { + var steamEndpointOption = allEndpoints.OfType().FirstOrNone(); + var eosEndpointOption = allEndpoints.OfType().FirstOrNone(); + if (SteamManager.IsInitialized) + { + if (steamEndpointOption.TryUnwrap(out var steamEndpoint)) { return steamEndpoint; } + } + if (EosInterface.Core.IsInitialized) + { + if (eosEndpointOption.TryUnwrap(out var eosEndpoint)) { return eosEndpoint; } + } + + throw new Exception($"Couldn't pick out a primary endpoint: {string.Join(", ", allEndpoints.Select(e => e.GetType().Name))}"); + } + + public P2PClientPeer(ImmutableArray allEndpoints, Callbacks callbacks) + : base( + GetPrimaryEndpoint(allEndpoints), + allEndpoints.Cast().ToImmutableArray(), + callbacks, + Option.None) { ServerConnection = null; isActive = false; - - if (!(ServerEndpoint is SteamP2PEndpoint steamIdEndpoint)) - { - throw new InvalidCastException("endPoint is not SteamId"); - } - - hostSteamId = steamIdEndpoint.SteamId; } public override void Start() { - if (isActive) { return; } - ContentPackageOrderReceived = false; - steamAuthTicket = SteamManager.GetAuthSessionTicketForMultiplayer(ServerEndpoint); - //TODO: wait for GetAuthSessionTicketResponse_t + ServerConnection = ServerEndpoint.MakeConnectionFromEndpoint(); - if (steamAuthTicket == null) + var socketCallbacks = new P2PSocket.Callbacks(OnIncomingConnection, OnConnectionClosed, OnP2PData); + var socketCreateResult = ServerEndpoint switch { - throw new Exception("GetAuthSessionTicket returned null"); - } - - Steamworks.SteamNetworking.ResetActions(); - Steamworks.SteamNetworking.OnP2PSessionRequest = OnIncomingConnection; - Steamworks.SteamNetworking.OnP2PConnectionFailed = OnConnectionFailed; - - Steamworks.SteamNetworking.AllowP2PPacketRelay(true); - - ServerConnection = new SteamP2PConnection(hostSteamId); - ServerConnection.SetAccountInfo(new AccountInfo(hostSteamId)); - - var headers = new PeerPacketHeaders - { - DeliveryMethod = DeliveryMethod.Reliable, - PacketHeader = PacketHeader.IsConnectionInitializationStep, - Initialization = ConnectionInitialization.ConnectionStarted + EosP2PEndpoint => EosP2PSocket.Create(socketCallbacks), + SteamP2PEndpoint steamP2PEndpoint => SteamConnectSocket.Create(steamP2PEndpoint, socketCallbacks), + _ => throw new Exception($"Invalid server endpoint: {ServerEndpoint.GetType()} {ServerEndpoint}") }; - SendMsgInternal(headers, null); + socket = socketCreateResult.TryUnwrapSuccess(out var s) + ? s + : throw new Exception($"Failed to create socket for {ServerEndpoint}: {socketCreateResult}"); + TaskPool.Add( + $"{nameof(P2PClientPeer)}.GetAuthTicket", + AuthenticationTicket.Create(ServerEndpoint), + t => + { + if (!t.TryGetResult(out Option authenticationTicket)) + { + Close(PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + return; + } + authTicket = authenticationTicket; - initializationStep = ConnectionInitialization.SteamTicketAndVersion; + var headers = new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsConnectionInitializationStep, + Initialization = ConnectionInitialization.ConnectionStarted + }; + SendMsgInternal(headers, null); + }); + initializationStep = ConnectionInitialization.AuthInfoAndVersion; timeout = NetworkConnection.TimeoutThreshold; heartbeatTimer = 1.0; - connectionStatusTimer = 0.0; isActive = true; } - private void OnIncomingConnection(Steamworks.SteamId steamId) + private bool OnIncomingConnection(P2PEndpoint remoteEndpoint) { - if (!isActive) { return; } + if (remoteEndpoint == ServerEndpoint) + { + return true; + } - if (steamId == hostSteamId.Value) + if (initializationStep != ConnectionInitialization.Password && + initializationStep != ConnectionInitialization.ContentPackageOrder && + initializationStep != ConnectionInitialization.Success) { - Steamworks.SteamNetworking.AcceptP2PSessionWithUser(steamId); - } - else if (initializationStep != ConnectionInitialization.Password && - initializationStep != ConnectionInitialization.ContentPackageOrder && - initializationStep != ConnectionInitialization.Success) - { - DebugConsole.ThrowError("Connection from incorrect SteamID was rejected: " + - $"expected {hostSteamId}," + - $"got {new SteamId(steamId)}"); + DebugConsole.AddWarning( + "Connection from incorrect endpoint was rejected: " + + $"expected {ServerEndpoint}, " + + $"got {remoteEndpoint}"); } + + return false; } - private void OnConnectionFailed(Steamworks.SteamId steamId, Steamworks.P2PSessionError error) + private void OnConnectionClosed(P2PEndpoint remoteEndpoint, PeerDisconnectPacket peerDisconnectPacket) { - if (!isActive) { return; } + if (remoteEndpoint != ServerEndpoint) { return; } - if (steamId != hostSteamId.Value) { return; } - - Close(PeerDisconnectPacket.SteamP2PError(error)); + Close(peerDisconnectPacket); } - private void OnP2PData(ulong steamId, byte[] data, int dataLength) + private void OnP2PData(P2PEndpoint senderEndpoint, IReadMessage inc) { if (!isActive) { return; } - if (steamId != hostSteamId.Value) { return; } + receivedBytes += inc.LengthBytes; + + if (senderEndpoint != ServerEndpoint) { return; } timeout = Screen.Selected == GameMain.GameScreen ? NetworkConnection.TimeoutThresholdInGame : NetworkConnection.TimeoutThreshold; - try - { - IReadMessage inc = new ReadOnlyMessage(data, false, 0, dataLength, ServerConnection); - ProcessP2PData(inc); - } - catch (Exception e) - { - string errorMsg = $"Client failed to read an incoming P2P message. {{{e}}}\n{e.StackTrace.CleanupStackTrace()}"; - GameAnalyticsManager.AddErrorEventOnce($"SteamP2PClientPeer.OnP2PData:ClientReadException{e.TargetSite}", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); -#if DEBUG - DebugConsole.ThrowError(errorMsg); -#else - if (GameSettings.CurrentConfig.VerboseLogging) { DebugConsole.ThrowError(errorMsg); } -#endif - } - } - - private void ProcessP2PData(IReadMessage inc) - { - var (deliveryMethod, packetHeader, initialization) = INetSerializableStruct.Read(inc); + var (_, packetHeader, initialization) = INetSerializableStruct.Read(inc); if (!packetHeader.IsServerMessage()) { return; } @@ -138,9 +144,8 @@ namespace Barotrauma.Networking { if (!initialization.HasValue) { return; } - var relayPacket = INetSerializableStruct.Read(inc); + var relayPacket = INetSerializableStruct.Read(inc); - SteamManager.JoinLobby(relayPacket.LobbyID, false); if (initializationStep != ConnectionInitialization.Success) { incomingInitializationMessages.Add(new IncomingInitializationMessage @@ -150,6 +155,14 @@ namespace Barotrauma.Networking }); } } + else if (packetHeader.IsDataFragment()) + { + var completeMessageOption = defragmenter.ProcessIncomingFragment(INetSerializableStruct.Read(inc)); + if (!completeMessageOption.TryUnwrap(out var completeMessage)) { return; } + + int completeMessageLengthBits = completeMessage.Length * 8; + incomingDataMessages.Add(new ReadWriteMessage(completeMessage.ToArray(), 0, completeMessageLengthBits, copyBuf: false)); + } else if (packetHeader.IsHeartbeatMessage()) { return; //TODO: implement heartbeats @@ -177,41 +190,7 @@ namespace Barotrauma.Networking heartbeatTimer -= deltaTime; - if (initializationStep != ConnectionInitialization.Password && - initializationStep != ConnectionInitialization.ContentPackageOrder && - initializationStep != ConnectionInitialization.Success) - { - connectionStatusTimer -= deltaTime; - if (connectionStatusTimer <= 0.0) - { - if (Steamworks.SteamNetworking.GetP2PSessionState(hostSteamId.Value) is { } state) - { - if (state.P2PSessionError != Steamworks.P2PSessionError.None) - { - Close(PeerDisconnectPacket.SteamP2PError(state.P2PSessionError)); - } - } - else - { - Close(PeerDisconnectPacket.WithReason(DisconnectReason.Timeout)); - } - - connectionStatusTimer = 1.0f; - } - } - - for (int i = 0; i < 100; i++) - { - if (!Steamworks.SteamNetworking.IsP2PPacketAvailable()) { break; } - - var packet = Steamworks.SteamNetworking.ReadP2PPacket(); - if (packet is { SteamId: var steamId, Data: var data }) - { - OnP2PData(steamId, data, data.Length); - if (!isActive) { return; } - receivedBytes += data.Length; - } - } + socket?.ProcessIncomingMessages(); GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.ReceivedBytes, receivedBytes); GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.SentBytes, sentBytes); @@ -258,8 +237,7 @@ namespace Barotrauma.Networking analyticsTag: "NoContentPackages"); return; } - callbacks.OnInitializationComplete.Invoke(); - initializationStep = ConnectionInitialization.Success; + OnInitializationComplete(); } else { @@ -287,6 +265,21 @@ namespace Barotrauma.Networking if (!isActive) { return; } byte[] bufAux = msg.PrepareForSending(compressPastThreshold, out bool isCompressed, out _); + if (bufAux.Length > MessageFragment.MaxSize) + { + var fragments = fragmenter.FragmentMessage(msg.Buffer.AsSpan()[..msg.LengthBytes]); + foreach (var fragment in fragments) + { + var fragmentHeaders = new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsDataFragment, + Initialization = null + }; + SendMsgInternal(fragmentHeaders, fragment); + } + return; + } var headers = new PeerPacketHeaders { @@ -346,8 +339,6 @@ namespace Barotrauma.Networking { if (!isActive) { return; } - SteamManager.LeaveLobby(); - isActive = false; var headers = new PeerPacketHeaders @@ -360,11 +351,9 @@ namespace Barotrauma.Networking Thread.Sleep(100); - Steamworks.SteamNetworking.ResetActions(); - Steamworks.SteamNetworking.CloseP2PSessionWithUser(hostSteamId.Value); - - if (steamAuthTicket.TryUnwrap(out var ticket)) { ticket.Cancel(); } - steamAuthTicket = Option.None; + socket?.CloseConnection(ServerEndpoint); + socket?.Dispose(); + socket = null; callbacks.OnDisconnect.Invoke(peerDisconnectPacket); } @@ -374,33 +363,62 @@ namespace Barotrauma.Networking IWriteMessage msgToSend = new WriteOnlyMessage(); msgToSend.WriteNetSerializableStruct(headers); body?.Write(msgToSend); - ForwardToSteamP2P(msgToSend, headers.DeliveryMethod); + ForwardToRemotePeer(msgToSend, headers.DeliveryMethod); } - private void ForwardToSteamP2P(IWriteMessage msg, DeliveryMethod deliveryMethod) + private void ForwardToRemotePeer(IWriteMessage msg, DeliveryMethod deliveryMethod) { + if (!isActive) { return; } + if (socket is null) { return; } + heartbeatTimer = 5.0; + int length = msg.LengthBytes; - bool successSend = Steamworks.SteamNetworking.SendP2PPacket(hostSteamId.Value, msg.Buffer, length, 0, deliveryMethod.ToSteam()); + if (length + 4 >= MsgConstants.MTU) + { + DebugConsole.Log($"WARNING: message length comes close to exceeding MTU, forcing reliable send ({length} bytes)"); + deliveryMethod = DeliveryMethod.Reliable; + } + + bool success = socket.SendMessage(ServerEndpoint, msg, deliveryMethod); + sentBytes += length; - if (successSend) { return; } + if (success) { return; } if (deliveryMethod is DeliveryMethod.Unreliable) { DebugConsole.Log($"WARNING: message couldn't be sent unreliably, forcing reliable send ({length} bytes)"); - successSend = Steamworks.SteamNetworking.SendP2PPacket(hostSteamId.Value, msg.Buffer, length, 0, DeliveryMethod.Reliable.ToSteam()); + success = socket.SendMessage(ServerEndpoint, msg, DeliveryMethod.Reliable); sentBytes += length; } - if (!successSend) + if (!success) { DebugConsole.AddWarning($"Failed to send message to remote peer! ({length} bytes)"); } } + protected override async Task> GetAccountId() + { + if (SteamManager.IsInitialized) { return SteamManager.GetSteamId().Select(id => (AccountId)id); } + + if (EosInterface.IdQueries.GetLoggedInPuids() is not { Length: > 0 } puids) + { + return Option.None; + } + var externalAccountIdsResult = await EosInterface.IdQueries.GetSelfExternalAccountIds(puids[0]); + if (!externalAccountIdsResult.TryUnwrapSuccess(out var externalAccountIds) + || externalAccountIds is not { Length: > 0 }) + { + return Option.None; + } + return Option.Some(externalAccountIds[0]); + } + #if DEBUG + public override void ForceTimeOut() { timeout = 0.0f; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2POwnerPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2POwnerPeer.cs new file mode 100644 index 000000000..0b2a340da --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/P2POwnerPeer.cs @@ -0,0 +1,557 @@ +#nullable enable +using Barotrauma.Extensions; +using Barotrauma.Steam; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Barotrauma.Networking +{ + sealed class P2POwnerPeer : ClientPeer + { + private P2PSocket? socket; + private readonly ImmutableDictionary authenticators; + + private readonly P2PEndpoint selfPrimaryEndpoint; + private AccountInfo selfAccountInfo; + + private long sentBytes, receivedBytes; + + private sealed class RemotePeer + { + public enum AuthenticationStatus + { + NotAuthenticated, + AuthenticationPending, + SuccessfullyAuthenticated + } + + public readonly P2PEndpoint Endpoint; + public AccountInfo AccountInfo; + + public readonly record struct DisconnectInfo( + double TimeToGiveUp, + PeerDisconnectPacket Packet); + + public Option PendingDisconnect; + public AuthenticationStatus AuthStatus; + + public readonly record struct UnauthedMessage(byte[] Bytes, int LengthBytes); + + public readonly List UnauthedMessages; + + public RemotePeer(P2PEndpoint endpoint) + { + Endpoint = endpoint; + AccountInfo = AccountInfo.None; + PendingDisconnect = Option.None; + AuthStatus = AuthenticationStatus.NotAuthenticated; + + UnauthedMessages = new List(); + } + } + + private readonly List remotePeers = new(); + + public P2POwnerPeer(Callbacks callbacks, int ownerKey, ImmutableArray allEndpoints) : + base(new PipeEndpoint(), allEndpoints.Cast().ToImmutableArray(), callbacks, Option.Some(ownerKey)) + { + ServerConnection = null; + + isActive = false; + + var selfSteamEndpoint = allEndpoints.FirstOrNone(e => e is SteamP2PEndpoint); + var selfEosEndpoint = allEndpoints.FirstOrNone(e => e is EosP2PEndpoint); + var selfPrimaryEndpointOption = selfSteamEndpoint.Fallback(selfEosEndpoint); + if (!selfPrimaryEndpointOption.TryUnwrap(out var selfPrimaryEndpointNotNull)) + { + throw new Exception("Could not determine endpoint for P2POwnerPeer"); + } + selfPrimaryEndpoint = selfPrimaryEndpointNotNull; + selfAccountInfo = AccountInfo.None; + authenticators = Authenticator.GetAuthenticatorsForHost(Option.Some(selfPrimaryEndpoint)); + } + + public override void Start() + { + if (isActive) { return; } + + initializationStep = ConnectionInitialization.AuthInfoAndVersion; + + ServerConnection = new PipeConnection(Option.None) + { + Status = NetworkConnectionStatus.Connected + }; + + remotePeers.Clear(); + + var socketCallbacks = new P2PSocket.Callbacks(OnIncomingConnection, OnConnectionClosed, OnP2PData); + var socketCreateResult = DualStackP2PSocket.Create(socketCallbacks); + socket = socketCreateResult.TryUnwrapSuccess(out var s) + ? s + : throw new Exception($"Failed to create dual-stack socket: {socketCreateResult}"); + + TaskPool.Add("P2POwnerPeer.GetAccountId", GetAccountId(), t => + { + if (t.TryGetResult(out Option accountIdOption) && accountIdOption.TryUnwrap(out var accountId)) + { + selfAccountInfo = new AccountInfo(accountId); + } + + if (selfAccountInfo.IsNone) + { + Close(PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + } + }); + + isActive = true; + } + + private bool OnIncomingConnection(P2PEndpoint remoteEndpoint) + { + if (!isActive) { return false; } + + if (remotePeers.None(p => p.Endpoint == remoteEndpoint)) + { + remotePeers.Add(new RemotePeer(remoteEndpoint)); + } + + return true; + } + + private void OnConnectionClosed(P2PEndpoint remoteEndpoint, PeerDisconnectPacket disconnectPacket) + { + var remotePeer + = remotePeers.Find(p => p.Endpoint == remoteEndpoint); + if (remotePeer is null) { return; } + CommunicatePeerDisconnectToServerProcess( + remotePeer, + remotePeer.PendingDisconnect.Select(d => d.Packet).Fallback(disconnectPacket)); + } + + private void OnP2PData(P2PEndpoint senderEndpoint, IReadMessage inc) + { + if (!isActive) { return; } + + receivedBytes += inc.LengthBytes; + + var remotePeer = remotePeers.Find(p => p.Endpoint == senderEndpoint); + if (remotePeer is null) { return; } + if (remotePeer.PendingDisconnect.IsSome()) { return; } + + var peerPacketHeaders = INetSerializableStruct.Read(inc); + + PacketHeader packetHeader = peerPacketHeaders.PacketHeader; + + if (packetHeader.IsConnectionInitializationStep()) + { + ConnectionInitialization initialization = peerPacketHeaders.Initialization ?? throw new Exception("Initialization step missing"); + if (initialization == ConnectionInitialization.AuthInfoAndVersion + && remotePeer.AuthStatus == RemotePeer.AuthenticationStatus.NotAuthenticated) + { + StartAuthTask(inc, remotePeer); + } + } + + if (remotePeer.AuthStatus == RemotePeer.AuthenticationStatus.AuthenticationPending) + { + remotePeer.UnauthedMessages.Add(new RemotePeer.UnauthedMessage(inc.Buffer, inc.LengthBytes)); + } + else + { + IWriteMessage outMsg = new WriteOnlyMessage(); + outMsg.WriteNetSerializableStruct(new P2POwnerToServerHeader + { + EndpointStr = remotePeer.Endpoint.StringRepresentation, + AccountInfo = remotePeer.AccountInfo + }); + outMsg.WriteBytes(inc.Buffer, 0, inc.LengthBytes); + + ForwardToServerProcess(outMsg); + } + } + + private void StartAuthTask(IReadMessage inc, RemotePeer remotePeer) + { + remotePeer.AuthStatus = RemotePeer.AuthenticationStatus.AuthenticationPending; + + var packet = INetSerializableStruct.Read(inc); + + void failAuth() + { + CommunicateDisconnectToRemotePeer(remotePeer, PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + } + + if (!packet.AuthTicket.TryUnwrap(out var authenticationTicket)) + { + failAuth(); + return; + } + if (!authenticators.TryGetValue(authenticationTicket.Kind, out var authenticator)) + { + failAuth(); + return; + } + TaskPool.Add($"P2POwnerPeer.VerifyRemotePeerAccountId", + authenticator.VerifyTicket(authenticationTicket), + t => + { + if (!t.TryGetResult(out AccountInfo accountInfo) + || accountInfo.IsNone) + { + failAuth(); + return; + } + + remotePeer.AccountInfo = accountInfo; + remotePeer.AuthStatus = RemotePeer.AuthenticationStatus.SuccessfullyAuthenticated; + foreach (var unauthedMessage in remotePeer.UnauthedMessages) + { + IWriteMessage msg = new WriteOnlyMessage(); + msg.WriteNetSerializableStruct(new P2POwnerToServerHeader + { + EndpointStr = remotePeer.Endpoint.StringRepresentation, + AccountInfo = accountInfo + }); + msg.WriteBytes(unauthedMessage.Bytes, 0, unauthedMessage.LengthBytes); + ForwardToServerProcess(msg); + } + remotePeer.UnauthedMessages.Clear(); + }); + } + + public override void Update(float deltaTime) + { + if (!isActive) { return; } + + if (ChildServerRelay.HasShutDown || ChildServerRelay.Process is not { HasExited: false }) + { + Close(PeerDisconnectPacket.WithReason(DisconnectReason.ServerCrashed)); + var msgBox = new GUIMessageBox(TextManager.Get("ConnectionLost"), ChildServerRelay.CrashMessage); + msgBox.Buttons[0].OnClicked += (btn, obj) => + { + GameMain.MainMenuScreen.Select(); + return false; + }; + return; + } + + if (selfAccountInfo.IsNone) { return; } + + for (int i = remotePeers.Count - 1; i >= 0; i--) + { + if (remotePeers[i].PendingDisconnect.TryUnwrap(out var pendingDisconnect) && pendingDisconnect.TimeToGiveUp < Timing.TotalTime) + { + CommunicatePeerDisconnectToServerProcess(remotePeers[i], pendingDisconnect.Packet); + } + } + + socket?.ProcessIncomingMessages(); + + GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.ReceivedBytes, receivedBytes); + GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.SentBytes, sentBytes); + + foreach (var incBuf in ChildServerRelay.Read()) + { + ChildServerRelay.DisposeLocalHandles(); + IReadMessage inc = new ReadOnlyMessage(incBuf, false, 0, incBuf.Length, ServerConnection); + HandleServerMessage(inc); + } + } + + private void HandleServerMessage(IReadMessage inc) + { + if (!isActive) { return; } + + var recipientInfo = INetSerializableStruct.Read(inc); + if (!recipientInfo.Endpoint.TryUnwrap(out var recipientEndpoint)) { return; } + var peerPacketHeaders = INetSerializableStruct.Read(inc); + + if (recipientEndpoint != selfPrimaryEndpoint) + { + HandleMessageForRemotePeer(peerPacketHeaders, recipientEndpoint, inc); + } + else + { + HandleMessageForOwner(peerPacketHeaders, inc); + } + } + + private static byte[] GetRemainingBytes(IReadMessage msg) + { + return msg.Buffer[msg.BytePosition..msg.LengthBytes]; + } + + private void HandleMessageForRemotePeer(PeerPacketHeaders peerPacketHeaders, P2PEndpoint recipientEndpoint, IReadMessage inc) + { + var (deliveryMethod, packetHeader, initialization) = peerPacketHeaders; + + if (!packetHeader.IsServerMessage()) + { + DebugConsole.ThrowError("Received non-server message meant for remote peer"); + return; + } + + RemotePeer? peer = remotePeers.Find(p => p.Endpoint == recipientEndpoint); + if (peer is null) { return; } + + if (packetHeader.IsDisconnectMessage()) + { + var packet = INetSerializableStruct.Read(inc); + CommunicateDisconnectToRemotePeer(peer, packet); + return; + } + + IWriteMessage outMsg = new WriteOnlyMessage(); + + outMsg.WriteNetSerializableStruct(new PeerPacketHeaders + { + DeliveryMethod = deliveryMethod, + PacketHeader = packetHeader, + Initialization = initialization + }); + + if (packetHeader.IsConnectionInitializationStep()) + { + var initRelayPacket = new P2PInitializationRelayPacket + { + LobbyID = 0, + Message = new PeerPacketMessage + { + Buffer = GetRemainingBytes(inc) + } + }; + + outMsg.WriteNetSerializableStruct(initRelayPacket); + } + else + { + byte[] userMessage = GetRemainingBytes(inc); + outMsg.WriteBytes(userMessage, 0, userMessage.Length); + } + + ForwardToRemotePeer(deliveryMethod, recipientEndpoint, outMsg); + } + + private void HandleMessageForOwner(PeerPacketHeaders peerPacketHeaders, IReadMessage inc) + { + var (_, packetHeader, _) = peerPacketHeaders; + + if (packetHeader.IsDisconnectMessage()) + { + DebugConsole.ThrowError("Received disconnect message from owned server"); + return; + } + + if (!packetHeader.IsServerMessage()) + { + DebugConsole.ThrowError("Received non-server message from owned server"); + return; + } + + if (packetHeader.IsHeartbeatMessage()) + { + return; //no timeout since we're using pipes, ignore this message + } + + if (packetHeader.IsConnectionInitializationStep()) + { + if (selfAccountInfo.IsNone) { throw new InvalidOperationException($"Cannot initialize {nameof(P2POwnerPeer)} because {nameof(selfAccountInfo)} is not defined"); } + IWriteMessage outMsg = new WriteOnlyMessage(); + outMsg.WriteNetSerializableStruct(new P2POwnerToServerHeader + { + EndpointStr = selfPrimaryEndpoint.StringRepresentation, + AccountInfo = selfAccountInfo + }); + outMsg.WriteNetSerializableStruct(new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsConnectionInitializationStep, + Initialization = ConnectionInitialization.AuthInfoAndVersion + }); + outMsg.WriteNetSerializableStruct(new P2PInitializationOwnerPacket( + Name: GameMain.Client.Name, + AccountId: selfAccountInfo.AccountId.Fallback(default(AccountId)!))); + ForwardToServerProcess(outMsg); + } + else + { + OnInitializationComplete(); + + PeerPacketMessage packet = INetSerializableStruct.Read(inc); + IReadMessage msg = new ReadOnlyMessage(packet.Buffer, packetHeader.IsCompressed(), 0, packet.Length, ServerConnection); + callbacks.OnMessageReceived.Invoke(msg); + } + } + + private void CommunicateDisconnectToRemotePeer(RemotePeer peer, PeerDisconnectPacket peerDisconnectPacket) + { + if (peer.PendingDisconnect.IsNone()) + { + peer.PendingDisconnect = Option.Some( + new RemotePeer.DisconnectInfo( + Timing.TotalTime + 3f, + peerDisconnectPacket)); + } + + IWriteMessage outMsg = new WriteOnlyMessage(); + outMsg.WriteNetSerializableStruct(new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsServerMessage | PacketHeader.IsDisconnectMessage + }); + outMsg.WriteNetSerializableStruct(peerDisconnectPacket); + + ForwardToRemotePeer(DeliveryMethod.Reliable, peer.Endpoint, outMsg); + } + + private void CommunicatePeerDisconnectToServerProcess(RemotePeer peer, PeerDisconnectPacket peerDisconnectPacket) + { + if (!remotePeers.Remove(peer)) { return; } + + IWriteMessage outMsg = new WriteOnlyMessage(); + outMsg.WriteNetSerializableStruct(new P2POwnerToServerHeader + { + EndpointStr = peer.Endpoint.StringRepresentation, + AccountInfo = peer.AccountInfo + }); + outMsg.WriteNetSerializableStruct(new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsDisconnectMessage + }); + outMsg.WriteNetSerializableStruct(peerDisconnectPacket); + if (peer.AccountInfo.AccountId.TryUnwrap(out var accountId)) + { + authenticators.Values.ForEach(authenticator => authenticator.EndAuthSession(accountId)); + } + + ForwardToServerProcess(outMsg); + + socket?.CloseConnection(peer.Endpoint); + } + + public override void SendPassword(string password) + { + //owner doesn't send passwords + } + + public override void Close(PeerDisconnectPacket peerDisconnectPacket) + { + if (!isActive) { return; } + + isActive = false; + + for (int i = remotePeers.Count - 1; i >= 0; i--) + { + CommunicateDisconnectToRemotePeer(remotePeers[i], peerDisconnectPacket); + } + + Thread.Sleep(100); + + for (int i = remotePeers.Count - 1; i >= 0; i--) + { + CommunicatePeerDisconnectToServerProcess(remotePeers[i], peerDisconnectPacket); + } + + socket?.Dispose(); + socket = null; + + callbacks.OnDisconnect.Invoke(peerDisconnectPacket); + } + + public override void Send(IWriteMessage msg, DeliveryMethod deliveryMethod, bool compressPastThreshold = true) + { + if (!isActive) { return; } + + IWriteMessage msgToSend = new WriteOnlyMessage(); + byte[] msgData = msg.PrepareForSending(compressPastThreshold, out bool isCompressed, out _); + msgToSend.WriteNetSerializableStruct(new P2POwnerToServerHeader + { + EndpointStr = selfPrimaryEndpoint.StringRepresentation, + AccountInfo = selfAccountInfo + }); + msgToSend.WriteNetSerializableStruct(new PeerPacketHeaders + { + DeliveryMethod = deliveryMethod, + PacketHeader = isCompressed ? PacketHeader.IsCompressed : PacketHeader.None + }); + msgToSend.WriteNetSerializableStruct(new PeerPacketMessage + { + Buffer = msgData + }); + ForwardToServerProcess(msgToSend); + } + + protected override void SendMsgInternal(PeerPacketHeaders headers, INetSerializableStruct? body) + { + //not currently used by P2POwnerPeer + throw new NotImplementedException(); + } + + private static void ForwardToServerProcess(IWriteMessage msg) + { + byte[] bufToSend = new byte[msg.LengthBytes]; + msg.Buffer[..msg.LengthBytes].CopyTo(bufToSend.AsSpan()); + ChildServerRelay.Write(bufToSend); + } + + private void ForwardToRemotePeer(DeliveryMethod deliveryMethod, P2PEndpoint recipient, IWriteMessage outMsg) + { + if (socket is null) { return; } + + int length = outMsg.LengthBytes; + + if (length + 4 >= MsgConstants.MTU) + { + DebugConsole.Log($"WARNING: message length comes close to exceeding MTU, forcing reliable send ({length} bytes)"); + deliveryMethod = DeliveryMethod.Reliable; + } + + var success = socket.SendMessage(recipient, outMsg, deliveryMethod); + + sentBytes += length; + + if (success) { return; } + + if (deliveryMethod is DeliveryMethod.Unreliable) + { + DebugConsole.Log($"WARNING: message couldn't be sent unreliably, forcing reliable send ({length} bytes)"); + success = socket.SendMessage(recipient, outMsg, DeliveryMethod.Reliable); + sentBytes += length; + } + + if (!success) + { + DebugConsole.AddWarning($"Failed to send message to remote peer! ({length} bytes)"); + } + } + + protected override async Task> GetAccountId() + { + if (SteamManager.IsInitialized) { return SteamManager.GetSteamId().Select(id => (AccountId)id); } + + if (EosInterface.IdQueries.GetLoggedInPuids() is not { Length: > 0 } puids) + { + return Option.None; + } + var externalAccountIdsResult = await EosInterface.IdQueries.GetSelfExternalAccountIds(puids[0]); + if (!externalAccountIdsResult.TryUnwrapSuccess(out var externalAccountIds) + || externalAccountIds is not { Length: > 0 }) + { + return Option.None; + } + return Option.Some(externalAccountIds[0]); + } + +#if DEBUG + public override void ForceTimeOut() + { + //TODO: reimplement? + } +#endif + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2POwnerPeer.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2POwnerPeer.cs deleted file mode 100644 index 27f6e1724..000000000 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/Primitives/Peers/SteamP2POwnerPeer.cs +++ /dev/null @@ -1,492 +0,0 @@ -#nullable enable -using Barotrauma.Steam; -using System; -using System.Collections.Generic; -using System.Threading; -using Barotrauma.Extensions; - -namespace Barotrauma.Networking -{ - sealed class SteamP2POwnerPeer : ClientPeer - { - private readonly SteamId selfSteamID; - private UInt64 ownerKey64 => unchecked((UInt64)ownerKey.Fallback(0)); - - private SteamId ReadSteamId(IReadMessage inc) => new SteamId(inc.ReadUInt64() ^ ownerKey64); - private void WriteSteamId(IWriteMessage msg, SteamId val) => msg.WriteUInt64(val.Value ^ ownerKey64); - - private long sentBytes, receivedBytes; - - private sealed class RemotePeer - { - public readonly SteamId SteamId; - public Option OwnerSteamId; - public double? DisconnectTime; - public bool Authenticating; - public bool Authenticated; - - public readonly struct UnauthedMessage - { - public readonly SteamId Sender; - public readonly byte[] Bytes; - public readonly int Length; - - public UnauthedMessage(SteamId sender, byte[] bytes) - { - Sender = sender; - Bytes = bytes; - Length = bytes.Length; - } - } - - public readonly List UnauthedMessages; - - public RemotePeer(SteamId steamId) - { - SteamId = steamId; - OwnerSteamId = Option.None(); - DisconnectTime = null; - Authenticating = false; - Authenticated = false; - - UnauthedMessages = new List(); - } - } - - private List remotePeers = null!; - - public SteamP2POwnerPeer(Callbacks callbacks, int ownerKey) : base(new PipeEndpoint(), callbacks, Option.Some(ownerKey)) - { - ServerConnection = null; - - isActive = false; - - selfSteamID = SteamManager.GetSteamId().TryUnwrap(out var steamId) - ? steamId - : throw new InvalidOperationException("Steamworks not initialized"); - } - - - public override void Start() - { - if (isActive) { return; } - - initializationStep = ConnectionInitialization.SteamTicketAndVersion; - - ServerConnection = new PipeConnection(selfSteamID) - { - Status = NetworkConnectionStatus.Connected - }; - - remotePeers = new List(); - - Steamworks.SteamNetworking.ResetActions(); - Steamworks.SteamNetworking.OnP2PSessionRequest = OnIncomingConnection; - Steamworks.SteamUser.OnValidateAuthTicketResponse += OnAuthChange; - - Steamworks.SteamNetworking.AllowP2PPacketRelay(true); - - isActive = true; - } - - private void OnAuthChange(Steamworks.SteamId steamId, Steamworks.SteamId ownerId, Steamworks.AuthResponse status) - { - RemotePeer? remotePeer = remotePeers.Find(p => p.SteamId.Value == steamId); - - if (remotePeer == null) { return; } - - if (status == Steamworks.AuthResponse.OK) - { - if (remotePeer.Authenticated) { return; } - - SteamId ownerSteamId = new SteamId(ownerId); - remotePeer.OwnerSteamId = Option.Some(ownerSteamId); - remotePeer.Authenticated = true; - remotePeer.Authenticating = false; - foreach (var unauthedMessage in remotePeer.UnauthedMessages) - { - IWriteMessage msg = new WriteOnlyMessage(); - WriteSteamId(msg, unauthedMessage.Sender); - WriteSteamId(msg, ownerSteamId); - msg.WriteBytes(unauthedMessage.Bytes, 0, unauthedMessage.Length); - ForwardToServerProcess(msg); - } - - remotePeer.UnauthedMessages.Clear(); - } - else - { - DisconnectPeer(remotePeer, PeerDisconnectPacket.SteamAuthError(status)); - } - } - - private void OnIncomingConnection(Steamworks.SteamId steamId) - { - if (!isActive) { return; } - - if (remotePeers.None(p => p.SteamId.Value == steamId)) - { - remotePeers.Add(new RemotePeer(new SteamId(steamId))); - } - - Steamworks.SteamNetworking.AcceptP2PSessionWithUser(steamId); //accept all connections, the server will figure things out later - } - - private void OnP2PData(ulong steamId, IReadMessage inc) - { - if (!isActive) { return; } - - RemotePeer? remotePeer = remotePeers.Find(p => p.SteamId.Value == steamId); - if (remotePeer == null) { return; } - - if (remotePeer.DisconnectTime != null) { return; } - - try - { - ProcessP2PData(steamId, remotePeer, inc); - } - catch (Exception e) - { - string errorMsg = $"Server failed to read an incoming P2P message. {{{e}}}\n{e.StackTrace.CleanupStackTrace()}"; - GameAnalyticsManager.AddErrorEventOnce($"SteamP2POwnerPeer.OnP2PData:OwnerReadException{e.TargetSite}", GameAnalyticsManager.ErrorSeverity.Error, errorMsg); -#if DEBUG - DebugConsole.ThrowError(errorMsg); -#else - if (GameSettings.CurrentConfig.VerboseLogging) { DebugConsole.ThrowError(errorMsg); } -#endif - } - } - - private void ProcessP2PData(ulong steamId, RemotePeer remotePeer, IReadMessage inc) - { - var (deliveryMethod, packetHeader, connectionInitialization) = INetSerializableStruct.Read(inc); - - if (remotePeer is { Authenticated: false, Authenticating: false } && packetHeader.IsConnectionInitializationStep()) - { - remotePeer.DisconnectTime = null; - - ConnectionInitialization initialization = connectionInitialization ?? throw new Exception("Initialization step missing"); - if (initialization == ConnectionInitialization.SteamTicketAndVersion) - { - remotePeer.Authenticating = true; - - var packet = INetSerializableStruct.Read(inc); - - packet.SteamAuthTicket.TryUnwrap(out var ticket); - - Steamworks.BeginAuthResult authSessionStartState = SteamManager.StartAuthSession(ticket, steamId); - if (authSessionStartState != Steamworks.BeginAuthResult.OK) - { - DisconnectPeer(remotePeer, PeerDisconnectPacket.SteamAuthError(authSessionStartState)); - return; - } - } - } - - var steamUserId = new SteamId(steamId); - if (remotePeer.Authenticating) - { - remotePeer.UnauthedMessages.Add(new RemotePeer.UnauthedMessage(steamUserId, inc.Buffer)); - } - else - { - IWriteMessage outMsg = new WriteOnlyMessage(); - WriteSteamId(outMsg, steamUserId); - WriteSteamId(outMsg, remotePeer.OwnerSteamId.Fallback(steamUserId)); - outMsg.WriteBytes(inc.Buffer, 0, inc.LengthBytes); - - ForwardToServerProcess(outMsg); - } - - } - - public override void Update(float deltaTime) - { - if (!isActive) { return; } - - if (ChildServerRelay.HasShutDown || !ChildServerRelay.IsProcessAlive) - { - var gameClient = GameMain.Client; - Close(PeerDisconnectPacket.WithReason(DisconnectReason.ServerCrashed)); - gameClient?.CreateServerCrashMessage(); - return; - } - - for (int i = remotePeers.Count - 1; i >= 0; i--) - { - if (remotePeers[i].DisconnectTime != null && remotePeers[i].DisconnectTime < Timing.TotalTime) - { - ClosePeerSession(remotePeers[i]); - } - } - - for (int i = 0; i < 100; i++) - { - if (!Steamworks.SteamNetworking.IsP2PPacketAvailable()) { break; } - - var packet = Steamworks.SteamNetworking.ReadP2PPacket(); - if (packet is { SteamId: var steamId, Data: var data }) - { - OnP2PData(steamId, new ReadWriteMessage(data, 0, data.Length * 8, false)); - receivedBytes += data.Length; - } - } - - GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.ReceivedBytes, receivedBytes); - GameMain.Client?.NetStats?.AddValue(NetStats.NetStatType.SentBytes, sentBytes); - - while (ChildServerRelay.Read(out byte[] incBuf)) - { - ChildServerRelay.DisposeLocalHandles(); - IReadMessage inc = new ReadOnlyMessage(incBuf, false, 0, incBuf.Length, ServerConnection); - HandleDataMessage(inc); - } - } - - private void HandleDataMessage(IReadMessage inc) - { - if (!isActive) { return; } - - SteamId recipientSteamId = ReadSteamId(inc); - - var peerPacketHeaders = INetSerializableStruct.Read(inc); - - if (recipientSteamId != selfSteamID) - { - HandleMessageForRemotePeer(peerPacketHeaders, recipientSteamId, inc); - } - else - { - HandleMessageForOwner(peerPacketHeaders, inc); - } - } - - private static byte[] GetRemainingBytes(IReadMessage msg) - { - return msg.Buffer[msg.BytePosition..msg.LengthBytes]; - } - - private void HandleMessageForRemotePeer(PeerPacketHeaders peerPacketHeaders, SteamId recipientSteamId, IReadMessage inc) - { - var (deliveryMethod, packetHeader, initialization) = peerPacketHeaders; - - if (!packetHeader.IsServerMessage()) - { - DebugConsole.ThrowError("Received non-server message meant for remote peer"); - return; - } - - RemotePeer? peer = remotePeers.Find(p => p.SteamId == recipientSteamId); - if (peer is null) { return; } - - if (packetHeader.IsDisconnectMessage()) - { - var packet = INetSerializableStruct.Read(inc); - DisconnectPeer(peer, packet); - return; - } - - IWriteMessage outMsg = new WriteOnlyMessage(); - - outMsg.WriteNetSerializableStruct(new PeerPacketHeaders - { - DeliveryMethod = deliveryMethod, - PacketHeader = packetHeader, - Initialization = initialization - }); - - if (packetHeader.IsConnectionInitializationStep()) - { - var initRelayPacket = new SteamP2PInitializationRelayPacket - { - LobbyID = SteamManager.CurrentLobbyID, - Message = new PeerPacketMessage - { - Buffer = GetRemainingBytes(inc) - } - }; - - outMsg.WriteNetSerializableStruct(initRelayPacket); - } - else - { - byte[] userMessage = GetRemainingBytes(inc); - outMsg.WriteBytes(userMessage, 0, userMessage.Length); - } - - ForwardToRemotePeer(deliveryMethod, recipientSteamId, outMsg); - } - - private void HandleMessageForOwner(PeerPacketHeaders peerPacketHeaders, IReadMessage inc) - { - var (_, packetHeader, _) = peerPacketHeaders; - - if (packetHeader.IsDisconnectMessage()) - { - DebugConsole.ThrowError("Received disconnect message from owned server"); - return; - } - - if (!packetHeader.IsServerMessage()) - { - DebugConsole.ThrowError("Received non-server message from owned server"); - return; - } - - if (packetHeader.IsHeartbeatMessage()) - { - return; //no timeout since we're using pipes, ignore this message - } - - if (packetHeader.IsConnectionInitializationStep()) - { - IWriteMessage outMsg = new WriteOnlyMessage(); - WriteSteamId(outMsg, selfSteamID); - WriteSteamId(outMsg, selfSteamID); - outMsg.WriteNetSerializableStruct(new PeerPacketHeaders - { - DeliveryMethod = DeliveryMethod.Reliable, - PacketHeader = PacketHeader.IsConnectionInitializationStep, - Initialization = ConnectionInitialization.SteamTicketAndVersion - }); - outMsg.WriteNetSerializableStruct(new SteamP2PInitializationOwnerPacket - { - OwnerName = GameMain.Client.Name - }); - ForwardToServerProcess(outMsg); - } - else - { - if (initializationStep != ConnectionInitialization.Success) - { - callbacks.OnInitializationComplete.Invoke(); - initializationStep = ConnectionInitialization.Success; - } - - PeerPacketMessage packet = INetSerializableStruct.Read(inc); - IReadMessage msg = new ReadOnlyMessage(packet.Buffer, packetHeader.IsCompressed(), 0, packet.Length, ServerConnection); - callbacks.OnMessageReceived.Invoke(msg); - } - } - - private void DisconnectPeer(RemotePeer peer, PeerDisconnectPacket peerDisconnectPacket) - { - peer.DisconnectTime ??= Timing.TotalTime + 1.0; - - IWriteMessage outMsg = new WriteOnlyMessage(); - outMsg.WriteNetSerializableStruct(new PeerPacketHeaders - { - DeliveryMethod = DeliveryMethod.Reliable, - PacketHeader = PacketHeader.IsServerMessage | PacketHeader.IsDisconnectMessage - }); - outMsg.WriteNetSerializableStruct(peerDisconnectPacket); - - Steamworks.SteamNetworking.SendP2PPacket(peer.SteamId.Value, outMsg.Buffer, outMsg.LengthBytes, 0, Steamworks.P2PSend.Reliable); - sentBytes += outMsg.LengthBytes; - } - - private void ClosePeerSession(RemotePeer peer) - { - Steamworks.SteamNetworking.CloseP2PSessionWithUser(peer.SteamId.Value); - remotePeers.Remove(peer); - } - - public override void SendPassword(string password) - { - //owner doesn't send passwords - } - - public override void Close(PeerDisconnectPacket peerDisconnectPacket) - { - if (!isActive) { return; } - - isActive = false; - - for (int i = remotePeers.Count - 1; i >= 0; i--) - { - DisconnectPeer(remotePeers[i], PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown)); - } - - Thread.Sleep(100); - - for (int i = remotePeers.Count - 1; i >= 0; i--) - { - ClosePeerSession(remotePeers[i]); - } - - callbacks.OnDisconnect.Invoke(peerDisconnectPacket); - - SteamManager.LeaveLobby(); - Steamworks.SteamNetworking.ResetActions(); - Steamworks.SteamUser.OnValidateAuthTicketResponse -= OnAuthChange; - } - - public override void Send(IWriteMessage msg, DeliveryMethod deliveryMethod, bool compressPastThreshold = true) - { - if (!isActive) { return; } - - IWriteMessage msgToSend = new WriteOnlyMessage(); - byte[] msgData = msg.PrepareForSending(compressPastThreshold, out bool isCompressed, out _); - WriteSteamId(msgToSend, selfSteamID); - WriteSteamId(msgToSend, selfSteamID); - msgToSend.WriteNetSerializableStruct(new PeerPacketHeaders - { - DeliveryMethod = deliveryMethod, - PacketHeader = isCompressed ? PacketHeader.IsCompressed : PacketHeader.None - }); - msgToSend.WriteNetSerializableStruct(new PeerPacketMessage - { - Buffer = msgData - }); - ForwardToServerProcess(msgToSend); - } - - protected override void SendMsgInternal(PeerPacketHeaders headers, INetSerializableStruct? body) - { - //not currently used by SteamP2POwnerPeer - throw new NotImplementedException(); - } - - private static void ForwardToServerProcess(IWriteMessage msg) - { - byte[] bufToSend = new byte[msg.LengthBytes]; - msg.Buffer[..msg.LengthBytes].CopyTo(bufToSend.AsSpan()); - ChildServerRelay.Write(bufToSend); - } - - private void ForwardToRemotePeer(DeliveryMethod deliveryMethod, SteamId recipent, IWriteMessage outMsg) - { - byte[] buf = outMsg.PrepareForSending(compressPastThreshold: false, out _, out int length); - - if (length + 4 >= MsgConstants.MTU) - { - DebugConsole.Log($"WARNING: message length comes close to exceeding MTU, forcing reliable send ({length} bytes)"); - deliveryMethod = DeliveryMethod.Reliable; - } - - bool successSend = Steamworks.SteamNetworking.SendP2PPacket(recipent.Value, buf, length, 0, deliveryMethod.ToSteam()); - sentBytes += length; - - if (successSend) { return; } - - if (deliveryMethod is DeliveryMethod.Unreliable) - { - DebugConsole.Log($"WARNING: message couldn't be sent unreliably, forcing reliable send ({length} bytes)"); - successSend = Steamworks.SteamNetworking.SendP2PPacket(recipent.Value, buf, length, 0, DeliveryMethod.Reliable.ToSteam()); - sentBytes += length; - } - - if (!successSend) - { - DebugConsole.AddWarning($"Failed to send message to remote peer! ({length} bytes)"); - } - } - -#if DEBUG - public override void ForceTimeOut() - { - //TODO: reimplement? - } -#endif - } -} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/FriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/FriendProvider.cs deleted file mode 100644 index 1a1753091..000000000 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/FriendProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -#nullable enable - -namespace Barotrauma -{ - abstract class FriendProvider - { - public abstract ServerListScreen.FriendInfo[] RetrieveFriends(); - public abstract void RetrieveAvatar(ServerListScreen.FriendInfo friend, ServerListScreen.AvatarSize avatarSize); - public abstract string GetUserName(); - } -} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/SteamFriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/SteamFriendProvider.cs deleted file mode 100644 index 9026de250..000000000 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/FriendProviders/SteamFriendProvider.cs +++ /dev/null @@ -1,67 +0,0 @@ -#nullable enable -using System; -using System.Linq; -using System.Threading.Tasks; -using Barotrauma.Networking; -using Barotrauma.Steam; -using Microsoft.Xna.Framework.Graphics; - -namespace Barotrauma -{ - class SteamFriendProvider : FriendProvider - { - private static ServerListScreen.FriendInfo FromSteamFriend(Steamworks.Friend steamFriend) - => new ServerListScreen.FriendInfo( - steamFriend.Name, - new SteamId(steamFriend.Id), - steamFriend.State switch - { - Steamworks.FriendState.Offline => ServerListScreen.FriendInfo.Status.Offline, - Steamworks.FriendState.Invisible => ServerListScreen.FriendInfo.Status.Offline, - _ when steamFriend.IsPlayingThisGame => ServerListScreen.FriendInfo.Status.PlayingBarotrauma, - _ when steamFriend.GameInfo is { GameID: var gameId } && gameId > 0 => ServerListScreen.FriendInfo.Status.PlayingAnotherGame, - _ => ServerListScreen.FriendInfo.Status.NotPlaying - }) - { - ServerName = steamFriend.GetRichPresence("servername"), - ConnectCommand = steamFriend.GetRichPresence("connect") is { } connectCmd - ? ToolBox.ParseConnectCommand(ToolBox.SplitCommand(connectCmd)) - : Option.None() - }; - - public override ServerListScreen.FriendInfo[] RetrieveFriends() - => SteamManager.IsInitialized - ? Steamworks.SteamFriends.GetFriends().Select(FromSteamFriend).ToArray() - : Array.Empty(); - - public override void RetrieveAvatar(ServerListScreen.FriendInfo friend, ServerListScreen.AvatarSize avatarSize) - { - if (!(friend.Id is SteamId steamId)) { return; } - - Func> avatarFunc = avatarSize switch - { - ServerListScreen.AvatarSize.Small => Steamworks.SteamFriends.GetSmallAvatarAsync, - ServerListScreen.AvatarSize.Medium => Steamworks.SteamFriends.GetMediumAvatarAsync, - ServerListScreen.AvatarSize.Large => Steamworks.SteamFriends.GetLargeAvatarAsync, - }; - TaskPool.Add($"Get{avatarSize}AvatarAsync", avatarFunc(steamId.Value), task => - { - if (!task.TryGetResult(out Steamworks.Data.Image? img)) { return; } - if (!(img is { } avatarImage)) { return; } - - if (friend.Avatar.TryUnwrap(out var prevAvatar)) - { - prevAvatar.Remove(); - } - - #warning TODO: create an avatar atlas? - var avatarTexture = new Texture2D(GameMain.Instance.GraphicsDevice, (int)avatarImage.Width, (int)avatarImage.Height); - avatarTexture.SetData(avatarImage.Data); - friend.Avatar = Option.Some(new Sprite(avatarTexture, null, null)); - }); - } - - public override string GetUserName() - => SteamManager.GetUsername(); - } -} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs index 259399b16..b2423ab37 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/PingUtils.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading.Tasks; +using Barotrauma.Extensions; using Steamworks.Data; using Color = Microsoft.Xna.Framework.Color; using Socket = System.Net.Sockets.Socket; @@ -34,19 +35,21 @@ namespace Barotrauma.Networking { if (CoroutineManager.IsCoroutineRunning("ConnectToServer")) { return; } - switch (serverInfo.Endpoint) + var endpointOption = serverInfo.Endpoints.FirstOrNone(e => e is not EosP2PEndpoint); + if (!endpointOption.TryUnwrap(out var endpoint)) { return; } + + switch (endpoint) { case LidgrenEndpoint { NetEndpoint: var endPoint }: - GetIPAddressPing(serverInfo, endPoint, onPingDiscovered); break; - case SteamP2PEndpoint steamP2PEndpoint: - TaskPool.Add($"EstimateSteamLobbyPing ({steamP2PEndpoint.StringRepresentation})", + case SteamP2PEndpoint: + TaskPool.Add($"EstimateSteamLobbyPing ({endpoint.StringRepresentation})", EstimateSteamLobbyPing(serverInfo), t => { - if (!t.TryGetResult(out Option ping)) { return; } - serverInfo.Ping = ping; + if (!t.TryGetResult(out Result ping)) { return; } + serverInfo.Ping = ping.TryUnwrapSuccess(out var ms) ? Option.Some(ms) : Option.None; onPingDiscovered(serverInfo); }); break; @@ -99,35 +102,57 @@ namespace Barotrauma.Networking return loadedLobby; } - - private static async Task> EstimateSteamLobbyPing(ServerInfo serverInfo) - { - if (!(serverInfo.Endpoint is SteamP2PEndpoint { SteamId: var ownerId })) { return Option.None(); } - while (!steamPingInfoReady) { await Task.Delay(50); } - Lobby lobby; + private enum SteamLobbyPingError + { + SteamPingUnsupported, + FailedToGetHostLocationData, + FailedToParseHostLocationData, + PingEstimationFailed + } + + private static async Task> EstimateSteamLobbyPing(ServerInfo serverInfo) + { + while (!steamPingInfoReady) + { + if (!SteamManager.IsInitialized) { return Result.Failure(SteamLobbyPingError.SteamPingUnsupported); } + await Task.Delay(50); + } + + string pingLocationStr = ""; if (serverInfo.MetadataSource.TryUnwrap(out SteamP2PServerProvider.DataSource src)) { - lobby = src.Lobby; + var lobby = src.Lobby; + pingLocationStr = lobby.GetData("steampinglocation"); + if (pingLocationStr.IsNullOrEmpty()) { pingLocationStr = lobby.GetData("pinglocation"); } } - else + else if (serverInfo.MetadataSource.TryUnwrap(out EosServerProvider.DataSource srcEos)) { - var friendLobby = await GetSteamLobbyForUser(ownerId); - if (friendLobby is null) { return Option.None(); } - lobby = friendLobby.Value; + pingLocationStr = srcEos.SteamPingLocation; + } + else if (serverInfo.Endpoints.OfType().FirstOrNone().TryUnwrap(out var steamP2PEndpoint)) + { + var friendLobby = await GetSteamLobbyForUser(steamP2PEndpoint.SteamId); + pingLocationStr = friendLobby?.GetData("steampinglocation") ?? ""; } - var pingLocation = NetPingLocation.TryParseFromString(lobby.GetData("pinglocation")); - + if (pingLocationStr.IsNullOrEmpty()) + { + return Result.Failure(SteamLobbyPingError.FailedToGetHostLocationData); + } + + var pingLocation = NetPingLocation.TryParseFromString(pingLocationStr); + if (pingLocation.HasValue && Steamworks.SteamNetworkingUtils.LocalPingLocation.HasValue) { int ping = Steamworks.SteamNetworkingUtils.LocalPingLocation.Value.EstimatePingTo(pingLocation.Value); - return ping >= 0 ? Option.Some(ping) : Option.None(); + if (ping < 0) { return Result.Failure(SteamLobbyPingError.PingEstimationFailed); } + return Result.Success(ping); } else { - return Option.None(); + return Result.Failure(SteamLobbyPingError.FailedToParseHostLocationData); } } @@ -173,7 +198,7 @@ namespace Barotrauma.Networking } if (endPoint?.Address == null) { return Option.None(); } - + //don't attempt to ping if the address is IPv6 and it's not supported if (endPoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !Socket.OSSupportsIPv6) { return Option.None(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs index 4ce4dd2e0..1111afa09 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerInfo.cs @@ -22,9 +22,9 @@ namespace Barotrauma.Networking public abstract void Write(XElement element); } - public Endpoint Endpoint { get; private set; } + public ImmutableArray Endpoints { get; } - public Option MetadataSource = Option.None(); + public Option MetadataSource = Option.None; [Serialize("", IsPropertySaveable.Yes)] public string ServerName { get; set; } = ""; @@ -75,6 +75,8 @@ namespace Barotrauma.Networking [Serialize("", IsPropertySaveable.Yes)] public LanguageIdentifier Language { get; set; } + public bool EosCrossplay { get; set; } + [Serialize("", IsPropertySaveable.Yes)] public string SelectedSub { get; set; } = string.Empty; @@ -84,49 +86,30 @@ namespace Barotrauma.Networking public bool Checked = false; - public readonly struct ContentPackageInfo - { - public readonly string Name; - public readonly string Hash; - public readonly Option Id; - - public ContentPackageInfo(string name, string hash, Option id) - { - Name = name; - Hash = hash; - Id = id; - } - - public ContentPackageInfo(ContentPackage pkg) - { - Name = pkg.Name; - Hash = pkg.Hash.StringRepresentation; - Id = pkg.UgcId; - } - } - - public ImmutableArray ContentPackages; + public ImmutableArray ContentPackages; public int ContentPackageCount; public bool IsModded => ContentPackages.Any(p => !GameMain.VanillaContent.NameMatches(p.Name)); - public ServerInfo(Endpoint endpoint) + public ServerInfo(params Endpoint[] endpoint) : this(endpoint.ToImmutableArray()) { } + + public ServerInfo(ImmutableArray endpoints) { SerializableProperties = SerializableProperty.GetProperties(this); - Endpoint = endpoint; - ContentPackages = ImmutableArray.Empty; + Endpoints = endpoints; + ContentPackages = ImmutableArray.Empty; } - public static ServerInfo FromServerConnection(NetworkConnection connection, ServerSettings serverSettings) + public static ServerInfo FromServerEndpoints(ImmutableArray endpoints, ServerSettings serverSettings) { - var serverInfo = new ServerInfo(connection.Endpoint) + var serverInfo = new ServerInfo(endpoints) { GameMode = GameMain.NetLobbyScreen.SelectedMode?.Identifier ?? Identifier.Empty, GameStarted = Screen.Selected != GameMain.NetLobbyScreen, GameVersion = GameMain.Version, PlayerCount = GameMain.Client.ConnectedClients.Count, - ContentPackages = ContentPackageManager.EnabledPackages.All.Select(p => new ContentPackageInfo(p)).ToImmutableArray(), + ContentPackages = ContentPackageManager.EnabledPackages.All.Select(p => new ServerListContentPackageInfo(p)).ToImmutableArray(), Ping = GameMain.Client.Ping, // ------------------------------------- @@ -225,7 +208,7 @@ namespace Barotrauma.Networking playStyleName.RectTransform.IsFixedSize = true; var serverType = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.0f), frame.RectTransform), - Endpoint?.ServerTypeString ?? string.Empty, + Endpoints.First().ServerTypeString, textAlignment: Alignment.TopLeft) { CanBeFocused = false @@ -460,8 +443,12 @@ namespace Barotrauma.Networking GameVersion = version; } if (int.TryParse(valueGetter("playercount"), out int playerCount)) { PlayerCount = playerCount; } - if (int.TryParse(valueGetter("maxplayernum"), out int maxPlayers)) { MaxPlayers = maxPlayers; } + + if (int.TryParse(valueGetter("maxplayers"), out int maxPlayers)) { MaxPlayers = maxPlayers; } + else if (int.TryParse(valueGetter("maxplayernum"), out maxPlayers)) { MaxPlayers = maxPlayers; } + if (Enum.TryParse(valueGetter("modeselectionmode"), out SelectionMode modeSelectionMode)) { ModeSelectionMode = modeSelectionMode; } + if (Enum.TryParse(valueGetter("subselectionmode"), out SelectionMode subSelectionMode)) { SubSelectionMode = subSelectionMode; } HasPassword = getBool("haspassword"); @@ -471,6 +458,8 @@ namespace Barotrauma.Networking AllowSpectating = getBool("allowspectating"); AllowRespawn = getBool("allowrespawn"); VoipEnabled = getBool("voicechatenabled"); + EosCrossplay = getBool("eoscrossplay"); + GameMode = valueGetter("gamemode")?.ToIdentifier() ?? Identifier.Empty; if (float.TryParse(valueGetter("traitors"), NumberStyles.Any, CultureInfo.InvariantCulture, out float traitorProbability)) { TraitorProbability = traitorProbability; } if (Enum.TryParse(valueGetter("playstyle"), out PlayStyle playStyle)) { PlayStyle = playStyle; } @@ -488,27 +477,21 @@ namespace Barotrauma.Networking } } - private static ContentPackageInfo[] ExtractContentPackageInfo(string serverName, Func valueGetter) + private static ServerListContentPackageInfo[] ExtractContentPackageInfo(string serverName, Func valueGetter) { //workaround to ServerRules queries truncating the values to 255 bytes int individualPackageIndex = 0; string? individualPackage = valueGetter($"contentpackage{individualPackageIndex}"); if (!individualPackage.IsNullOrEmpty()) { - List contentPackages = new List(); + List contentPackages = new List(); do { - string[] splitPackageInfo = individualPackage.Split(','); - if (splitPackageInfo.Length != 3) + if (!ServerListContentPackageInfo.ParseSingleEntry(individualPackage).TryUnwrap(out var info)) { - DebugConsole.Log( - $"Error in a server's content package list: malformed content package info ({individualPackage})."); - return Array.Empty(); + return Array.Empty(); } - string name = splitPackageInfo[0]; - string hash = splitPackageInfo[1]; - ulong.TryParse(splitPackageInfo[2], out ulong id); - contentPackages.Add(new ContentPackageInfo(name, hash, Option.Some(new SteamWorkshopId(id)))); + contentPackages.Add(info); individualPackageIndex++; individualPackage = valueGetter($"contentpackage{individualPackageIndex}"); @@ -518,43 +501,58 @@ namespace Barotrauma.Networking string? joinedNames = valueGetter("contentpackage"); string? joinedHashes = valueGetter("contentpackagehash"); - string? joinedWorkshopIds = valueGetter("contentpackageid"); + string? joinedUgcIds = valueGetter("contentpackageid"); - string[] contentPackageNames = joinedNames.IsNullOrEmpty() ? Array.Empty() : joinedNames.Split(','); - string[] contentPackageHashes = joinedHashes.IsNullOrEmpty() ? Array.Empty() : joinedHashes.Split(','); - #warning TODO: genericize - ulong[] contentPackageIds = joinedWorkshopIds.IsNullOrEmpty() ? new ulong[1] : SteamManager.ParseWorkshopIds(joinedWorkshopIds).ToArray(); + var contentPackageNames = joinedNames.IsNullOrEmpty() ? Array.Empty() : joinedNames.SplitEscaped(','); + var contentPackageHashes = joinedHashes.IsNullOrEmpty() ? Array.Empty() : joinedHashes.SplitEscaped(','); + var contentPackageIds = joinedUgcIds.IsNullOrEmpty() ? new string[1] { string.Empty } : joinedUgcIds.SplitEscaped(','); - if (contentPackageNames.Length != contentPackageHashes.Length || contentPackageHashes.Length != contentPackageIds.Length) + if (contentPackageNames.Count != contentPackageHashes.Count || contentPackageHashes.Count != contentPackageIds.Count) { DebugConsole.Log( - $"The number of names, hashes and Workshop IDs on server \"{serverName}\"" + - $" doesn't match: {contentPackageNames.Length} names ({string.Join(", ", contentPackageNames)}), {contentPackageHashes.Length} hashes, {contentPackageIds.Length} ids)"); - return Array.Empty(); + $"The number of names, hashes and UGC IDs on server \"{serverName}\"" + + $" doesn't match: {contentPackageNames.Count} names ({string.Join(", ", contentPackageNames)}), {contentPackageHashes.Count} hashes, {contentPackageIds.Count} ids)"); + return Array.Empty(); } return contentPackageNames .Zip(contentPackageHashes, (name, hash) => (name, hash)) .Zip(contentPackageIds, (t1, id) => - new ContentPackageInfo( + new ServerListContentPackageInfo( t1.name, t1.hash, - Option.Some(new SteamWorkshopId(id)))) + ContentPackageId.Parse(id))) .ToArray(); } public static Option FromXElement(XElement element) { + var endpoints = new List(); + string endpointStr = element.GetAttributeString("Endpoint", null) ?? element.GetAttributeString("OwnerID", null) ?? $"{element.GetAttributeString("IP", "")}:{element.GetAttributeInt("Port", 0)}"; + + if (Endpoint.Parse(endpointStr).TryUnwrap(out var endpoint)) + { + endpoints.Add(endpoint); + } + else + { + var multipleEndpointStrs + = element.GetAttributeStringArray("Endpoints", Array.Empty()); + endpoints.AddRange( + multipleEndpointStrs + .Select(Endpoint.Parse) + .NotNone()); + } - if (!Endpoint.Parse(endpointStr).TryUnwrap(out var endpoint)) { return Option.None(); } + if (endpoints.Count == 0) { return Option.None; } var gameVersionStr = element.GetAttributeString("GameVersion", ""); if (!Version.TryParse(gameVersionStr, out var gameVersion)) { gameVersion = GameMain.Version; } - var info = new ServerInfo(endpoint) + var info = new ServerInfo(endpoints.ToImmutableArray()) { GameVersion = gameVersion }; @@ -562,14 +560,14 @@ namespace Barotrauma.Networking info.MetadataSource = DataSource.Parse(element); - return Option.Some(info); + return Option.Some(info); } public XElement ToXElement() { XElement element = new XElement(GetType().Name); - element.SetAttributeValue("Endpoint", Endpoint.ToString()); + element.SetAttributeValue("Endpoints", string.Join(",", Endpoints.Select(e => e.StringRepresentation))); element.SetAttributeValue("GameVersion", GameVersion.ToString()); SerializableProperty.SerializeProperties(this, element, saveIfDefault: true); @@ -588,9 +586,9 @@ namespace Barotrauma.Networking } public bool Equals(ServerInfo other) - => other.Endpoint == Endpoint; + => other.Endpoints.Any(e => Endpoints.Contains(e)); - public override int GetHashCode() => Endpoint.GetHashCode(); + public override int GetHashCode() => Endpoints.First().GetHashCode(); string ISerializableEntity.Name => "ServerInfo"; public Dictionary SerializableProperties { get; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/CompositeServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/CompositeServerProvider.cs index 205d4f034..fe4313003 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/CompositeServerProvider.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/CompositeServerProvider.cs @@ -15,7 +15,7 @@ namespace Barotrauma this.providers = providers.ToImmutableArray(); } - protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) + protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) { int providersFinished = 0; void ackFinishedProvider() diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/EosServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/EosServerProvider.cs new file mode 100644 index 000000000..c7a400b21 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/EosServerProvider.cs @@ -0,0 +1,139 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using System.Xml.Linq; +using Barotrauma.Networking; + +namespace Barotrauma; + +sealed class EosServerProvider : ServerProvider +{ + public sealed class DataSource : ServerInfo.DataSource + { + public readonly string SteamPingLocation; + + public DataSource(string steamPingLocation) + { + SteamPingLocation = steamPingLocation; + } + + public override void Write(XElement element) { /* do nothing */ } + } + + protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) + { + if (EosInterface.IdQueries.GetLoggedInPuids() is not { Length: > 0 } loggedInPuids) { return; } + + int finishedTaskCount = 0; + int totalTaskCount = EosInterface.Sessions.MaxBucketIndex + 1 - EosInterface.Sessions.MinBucketIndex; + + void countTaskFinished() + { + finishedTaskCount++; + if (finishedTaskCount == totalTaskCount) + { + onQueryCompleted(); + } + } + + void onTaskFinished(Task t) + { + using var janitor = Janitor.Start(); + janitor.AddAction(countTaskFinished); + + if (!t.TryGetResult( + out Result, EosInterface.Sessions.RemoteSession.Query.Error>? result)) + { + return; + } + + if (!result.TryUnwrapSuccess(out var sessions)) + { + return; + } + + var addedEndpoints = new HashSet(); + foreach (var session in sessions) + { + if (!session.Attributes.TryGetValue("ServerName".ToIdentifier(), out var serverName)) + { + continue; + } + + var endpointOption = Endpoint.Parse(session.HostAddress); + if (!endpointOption.TryUnwrap(out var primaryEndpoint)) + { + continue; + } + + var endpoints = new List { primaryEndpoint }; + if (primaryEndpoint is EosP2PEndpoint + && session.Attributes.TryGetValue("SteamP2PEndpoint".ToIdentifier(), out var steamIdStr) + && SteamP2PEndpoint.Parse(steamIdStr).TryUnwrap(out var steamP2PEndpoint)) + { + endpoints.Add(steamP2PEndpoint); + } + else if (primaryEndpoint is LidgrenEndpoint + { + Address: LidgrenAddress address, Port: NetConfig.DefaultPort + } + && session.Attributes.TryGetValue("Port".ToIdentifier(), out var portStr) + && ushort.TryParse(portStr, out var port)) + { + // Port isn't included as part of the host address + // because it's filled in by EOS automatically, + // so extract the port from a separate attribute and + // fix up the endpoint here + primaryEndpoint = new LidgrenEndpoint(address.NetAddress, port); + endpoints[0] = primaryEndpoint; + } + + // Prevent duplicate entries + if (endpoints.Intersect(addedEndpoints).Any()) + { + continue; + } + + addedEndpoints.UnionWith(endpoints); + + var serverInfo = new ServerInfo(endpoints.ToImmutableArray()) + { + ServerName = serverName + }; + serverInfo.UpdateInfo(key => + session.Attributes.TryGetValue(key.ToIdentifier(), out var value) ? value : string.Empty); + serverInfo.EosCrossplay = true; + serverInfo.Checked = true; + + if (session.Attributes.TryGetValue("steampinglocation".ToIdentifier(), out var steamPingLocation)) + { + serverInfo.MetadataSource = Option.Some((ServerInfo.DataSource)new DataSource(steamPingLocation)); + } + + onServerDataReceived(serverInfo, this); + } + }; + + for (int bucketIndex = EosInterface.Sessions.MinBucketIndex; bucketIndex <= EosInterface.Sessions.MaxBucketIndex; bucketIndex++) + { + var query = new EosInterface.Sessions.RemoteSession.Query( + BucketIndex: bucketIndex, + LocalUserId: loggedInPuids.First(), + MaxResults: 200, + Attributes: ImmutableDictionary.Empty); + + TaskPool.Add( + $"{nameof(EosServerProvider)}.{nameof(RetrieveServersImpl)}", + query.Run(), + onTaskFinished); + } + } + + public override void Cancel() + { + + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/ServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/ServerProvider.cs index 8664c58ed..7f80cd096 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/ServerProvider.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/ServerProvider.cs @@ -6,12 +6,12 @@ namespace Barotrauma { abstract class ServerProvider { - public void RetrieveServers(Action onServerDataReceived, Action onQueryCompleted) + public void RetrieveServers(Action onServerDataReceived, Action onQueryCompleted) { Cancel(); RetrieveServersImpl(onServerDataReceived, onQueryCompleted); } - protected abstract void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted); + protected abstract void RetrieveServersImpl(Action action, Action onQueryCompleted); public abstract void Cancel(); } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs index 678d89cdc..e31ccd06f 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamDedicatedServerProvider.cs @@ -46,42 +46,43 @@ namespace Barotrauma MetadataSource = Option.Some(new DataSource((UInt16)entry.QueryPort)) }); - private static void HandleResponsiveServer(Steamworks.Data.ServerInfo entry, Action onServerDataReceived) + private void HandleResponsiveServer(Steamworks.Data.ServerInfo entry, Action onServerDataReceived) { TaskPool.Add($"QueryServerRules (GetServers, {entry.Name}, {entry.Address})", entry.QueryRulesAsync(), t => { if (t.Status == TaskStatus.Faulted) { - TaskPool.PrintTaskExceptions(t, $"Failed to retrieve rules for {entry.Name}"); + TaskPool.PrintTaskExceptions(t, $"Failed to retrieve rules for {entry.Name}", msg => DebugConsole.ThrowError(msg)); return; } - if (!t.TryGetResult(out Dictionary rules)) { return; } + if (!t.TryGetResult(out Dictionary? rules)) { return; } if (rules is null) { return; } + if (!InfoFromListEntry(entry).TryUnwrap(out var serverInfo)) { return; } serverInfo.UpdateInfo(key => { if (rules.TryGetValue(key, out var val)) { return val; } return null; }); - serverInfo.Checked = true; //rules != null; + serverInfo.Checked = true; - onServerDataReceived(serverInfo); + onServerDataReceived(serverInfo, this); }); } - private static void HandleUnresponsiveServer(Steamworks.Data.ServerInfo entry, Action onServerDataReceived) + private void HandleUnresponsiveServer(Steamworks.Data.ServerInfo entry, Action onServerDataReceived) { //TODO: do we still want to list unresponsive servers? if (!InfoFromListEntry(entry).TryUnwrap(out var serverInfo)) { return; } - onServerDataReceived(serverInfo); + onServerDataReceived(serverInfo, this); } private Steamworks.ServerList.Internet? serverQuery; private CoroutineHandle? queryCoroutine; - protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) + protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) { if (!SteamManager.IsInitialized) { @@ -139,7 +140,7 @@ namespace Barotrauma CoroutineManager.StopCoroutines(selfQueryCoroutine); dequeue(); - if (t.Status == TaskStatus.Faulted) { TaskPool.PrintTaskExceptions(t, "Failed to retrieve servers"); } + if (t.Status == TaskStatus.Faulted) { TaskPool.PrintTaskExceptions(t, "Failed to retrieve servers", msg => DebugConsole.ThrowError(msg)); } selfServerQuery.Dispose(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamP2PServerProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamP2PServerProvider.cs index caf2e0a20..f76e9b435 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamP2PServerProvider.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Networking/ServerList/ServerProviders/SteamP2PServerProvider.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using System.Xml.Linq; using Barotrauma.Networking; @@ -24,7 +25,7 @@ namespace Barotrauma private object? queryRef = null; - protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) + protected override void RetrieveServersImpl(Action onServerDataReceived, Action onQueryCompleted) { if (!SteamManager.IsInitialized) { @@ -61,7 +62,7 @@ namespace Barotrauma // If queryRef != selfQueryRef, this query was cancelled if (!ReferenceEquals(selfQueryRef, queryRef)) { return; } - if (!t.TryGetResult(out Steamworks.Data.Lobby[] lobbies) + if (!t.TryGetResult(out Steamworks.Data.Lobby[]? lobbies) || lobbies is null || lobbies.Length == 0) { @@ -74,16 +75,23 @@ namespace Barotrauma string lobbyOwnerStr = lobby.GetData("lobbyowner") ?? ""; lobbyQuery = lobbyQuery.WithoutKeyValue("lobbyowner", lobbyOwnerStr); - string serverName = lobby.GetData("name") ?? ""; + string serverName = lobby.GetData("servername").FallbackNullOrEmpty(lobby.GetData("name")) ?? ""; if (string.IsNullOrEmpty(serverName)) { continue; } var ownerId = SteamId.Parse(lobbyOwnerStr); if (!ownerId.TryUnwrap(out var lobbyOwnerId)) { continue; } + + var eosP2PEndpointOption = EosP2PEndpoint + .Parse(lobby.GetData("EosEndpoint") ?? "") + .Select(e => (Endpoint)e); if (retrieved.Contains(lobbyOwnerId)) { continue; } retrieved.Add(lobbyOwnerId); - var serverInfo = new ServerInfo(new SteamP2PEndpoint(lobbyOwnerId)) + var endpoints = new List { new SteamP2PEndpoint(lobbyOwnerId) }; + if (eosP2PEndpointOption.TryUnwrap(out var eosP2PEndpoint)) { endpoints.Add(eosP2PEndpoint); } + + var serverInfo = new ServerInfo(endpoints.ToImmutableArray()) { ServerName = serverName, MetadataSource = Option.Some(new DataSource(lobby)) @@ -91,7 +99,7 @@ namespace Barotrauma serverInfo.UpdateInfo(key => lobby.GetData(key)); serverInfo.Checked = true; - onServerDataReceived(serverInfo); + onServerDataReceived(serverInfo, this); } startQuery(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Program.cs b/Barotrauma/BarotraumaClient/ClientSource/Program.cs index b541794b2..ad60eb631 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Program.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Program.cs @@ -8,6 +8,7 @@ using Barotrauma.Steam; using System.Diagnostics; using System.Runtime.InteropServices; using System.Xml.Linq; +using Barotrauma.Debugging; #if WINDOWS using SharpDX; @@ -17,7 +18,6 @@ using SharpDX; namespace Barotrauma { -#if WINDOWS || LINUX || OSX /// /// The main class. /// @@ -52,7 +52,10 @@ namespace Barotrauma Game = null; executableDir = Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location); Directory.SetCurrentDirectory(executableDir); - SteamManager.Initialize(); + DebugConsoleCore.Init( + newMessage: (s, c) => DebugConsole.NewMessage(s, c), + log: DebugConsole.Log); + StoreIntegration.Init(ref args); EnableNvOptimus(); Game = new GameMain(args); Game.Run(); @@ -188,6 +191,10 @@ namespace Barotrauma { sb.AppendLine("SteamManager initialized"); } + else if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + sb.AppendLine("Logged in to EOS connect"); + } if (GameMain.Client != null) { @@ -344,6 +351,4 @@ namespace Barotrauma } } -#endif - - } +} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignEndScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignEndScreen.cs index fc8859439..4ca192355 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignEndScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignEndScreen.cs @@ -49,7 +49,7 @@ namespace Barotrauma static void UnlockAchievement(string id) { - SteamAchievementManager.UnlockAchievement(id.ToIdentifier(), unlockClients: true); + AchievementManager.UnlockAchievement(id.ToIdentifier(), unlockClients: true); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs index 8400fab7d..f2706ca31 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/CampaignSetupUI/SinglePlayerCampaignSetupUI.cs @@ -9,7 +9,7 @@ using System.Xml.Linq; namespace Barotrauma { - class SinglePlayerCampaignSetupUI : CampaignSetupUI + sealed class SinglePlayerCampaignSetupUI : CampaignSetupUI { private GUIListBox subList; diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EditorNode.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EditorNode.cs index d0406a244..3210e1122 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EditorNode.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EditorNode.cs @@ -550,7 +550,10 @@ namespace Barotrauma width -= 16; } - valueText = ToolBox.WrapText(valueText, width, GUIStyle.SubHeadingFont.Value); + if (GUIStyle.SubHeadingFont.Value != null) + { + valueText = ToolBox.WrapText(valueText, width, GUIStyle.SubHeadingFont.Value); + } wrappedText = valueText; } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs index 4a1816928..e8806ca32 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/EventEditor/EventEditorScreen.cs @@ -974,7 +974,7 @@ namespace Barotrauma spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: GUI.SamplerState); GUI.Draw(Cam, spriteBatch); - if (!string.IsNullOrWhiteSpace(DrawnTooltip)) + if (!string.IsNullOrWhiteSpace(DrawnTooltip) && GUIStyle.SmallFont.Value != null) { string tooltip = ToolBox.WrapText(DrawnTooltip, 256.0f, GUIStyle.SmallFont.Value); GUI.DrawString(spriteBatch, PlayerInput.MousePosition + new Vector2(32, 32), tooltip, Color.White, Color.Black * 0.8f, 4, GUIStyle.SmallFont); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs similarity index 90% rename from Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen.cs rename to Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs index 0982130cf..76d16400e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/MainMenuScreen/MainMenuScreen.cs @@ -9,7 +9,7 @@ using Microsoft.Xna.Framework.Graphics; using RestSharp; using System; using System.Collections.Generic; -using System.Data.Common; +using System.Collections.Immutable; using System.Diagnostics; using Barotrauma.IO; using System.Linq; @@ -21,7 +21,7 @@ using Barotrauma.Steam; namespace Barotrauma { - class MainMenuScreen : Screen + sealed class MainMenuScreen : Screen { private enum Tab { @@ -33,7 +33,7 @@ namespace Barotrauma JoinServer = 5, CharacterEditor = 6, SubmarineEditor = 7, - SteamWorkshop = 8, + Mods = 8, Credits = 9, Empty = 10 } @@ -59,6 +59,8 @@ namespace Barotrauma private GUIImage playstyleBanner; private GUITextBlock playstyleDescription; + private static string RemoteContentUrl => GameSettings.CurrentConfig.RemoteMainMenuContentUrl; + private readonly GUIComponent remoteContentContainer; private XDocument remoteContentDoc; @@ -76,11 +78,76 @@ namespace Barotrauma private GUITextBlock tutorialHeader, tutorialDescription; private GUIListBox tutorialList; + private readonly GUITextBlock gameAnalyticsStatusText; + + private readonly GUILayoutGroup leftTextFooterLayout; + private readonly GUILayoutGroup rightTextFooterLayout; + private GUIComponent versionMismatchWarning; #region Creation - public MainMenuScreen(GameMain game) + public MainMenuScreen(GameMain game) : base() { + leftTextFooterLayout = createTextFooter(); + rightTextFooterLayout = createTextFooter(); + + gameAnalyticsStatusText = createLeftText(TextManager.Get($"GameAnalyticsStatus.{GameAnalyticsManager.Consent.Unknown}")); + createLeftText("Barotrauma v" + GameMain.Version + " (" + AssemblyInfo.BuildString + ", branch " + AssemblyInfo.GitBranch + ", revision " + AssemblyInfo.GitRevision + ")"); + + var privacyPolicyText = createRightText(TextManager.Get("privacypolicy").Fallback("Privacy policy")); + (Rectangle Rect, bool MouseOn) getPrivacyPolicyHoverRect() + { + var textSize = privacyPolicyText.Font.MeasureString(privacyPolicyText.Text); + var bottomRight = privacyPolicyText.Rect.Location.ToVector2() + + privacyPolicyText.TextPos + + privacyPolicyText.TextOffset; + var rect = new Rectangle((bottomRight - textSize).ToPoint(), textSize.ToPoint()); + bool mouseOn = rect.Contains(PlayerInput.LatestMousePosition) && GUI.IsMouseOn(privacyPolicyText); + return (rect, mouseOn); + } + new GUICustomComponent(new RectTransform(Vector2.One, privacyPolicyText.RectTransform), + onUpdate: (dt, component) => + { + var (_, mouseOn) = getPrivacyPolicyHoverRect(); + if (mouseOn && PlayerInput.PrimaryMouseButtonClicked()) + { + GameMain.ShowOpenUriPrompt("https://privacypolicy.daedalic.com"); + } + }, + onDraw: (sb, component) => + { + var (rect, mouseOn) = getPrivacyPolicyHoverRect(); + Color color = mouseOn ? Color.White : Color.White * 0.7f; + privacyPolicyText.TextColor = color; + GUI.DrawLine(sb, new Vector2(rect.Left, rect.Bottom), new Vector2(rect.Right, rect.Bottom), color); + }); + + createRightText("© " + DateTime.Now.Year + " Undertow Games & FakeFish. All rights reserved."); + createRightText("© " + DateTime.Now.Year + " Daedalic Entertainment GmbH. The Daedalic logo is a trademark of Daedalic Entertainment GmbH, Germany. All rights reserved."); + + GUILayoutGroup createTextFooter() + => new GUILayoutGroup(new RectTransform((1.0f, 0.06f), Frame.RectTransform, Anchor.BottomCenter)) + { + ChildAnchor = Anchor.BottomLeft + }; + + GUITextBlock createTextInFooter(GUILayoutGroup footer, LocalizedString str, Alignment textAlignment) + { + var textBlock = new GUITextBlock( + rectT: new RectTransform((1.0f, 0.3f), footer.RectTransform), + text: str, + textAlignment: textAlignment, + font: GUIStyle.SmallFont, + textColor: Color.White * 0.7f); + textBlock.RectTransform.SetAsFirstChild(); + return textBlock; + } + + GUITextBlock createLeftText(LocalizedString str) + => createTextInFooter(leftTextFooterLayout, str, Alignment.BottomLeft); + GUITextBlock createRightText(LocalizedString str) + => createTextInFooter(rightTextFooterLayout, str, Alignment.BottomRight); + GameMain.Instance.ResolutionChanged += () => { SetMenuTabPositioning(); @@ -306,7 +373,7 @@ namespace Barotrauma { ForceUpperCase = ForceUpperCase.Yes, Enabled = true, - UserData = Tab.SteamWorkshop, + UserData = Tab.Mods, OnClicked = SelectTab }; @@ -321,7 +388,7 @@ namespace Barotrauma }, Visible = false }; - + new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), customizeList.RectTransform), TextManager.Get("SubEditorButton"), textAlignment: Alignment.Left, style: "MainMenuGUIButton") { ForceUpperCase = ForceUpperCase.Yes, @@ -378,7 +445,7 @@ namespace Barotrauma OnClicked = (button, userData) => { string url = TextManager.Get("EditorDisclaimerWikiUrl").Fallback("https://barotraumagame.com/wiki").Value; - GameMain.ShowOpenUrlInWebBrowserPrompt(url, promptExtensionTag: "wikinotice"); + GameMain.ShowOpenUriPrompt(url, promptExtensionTag: "wikinotice"); return true; } }; @@ -456,39 +523,40 @@ namespace Barotrauma menuTabs = new Dictionary { - [Tab.Settings] = new GUIFrame(new RectTransform(new Vector2(relativeSize.X, 0.8f), GUI.Canvas, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }, + [Tab.Settings] = new GUIFrame(new RectTransform(new Vector2(relativeSize.X, 0.8f), Frame.RectTransform, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }, style: null) { CanBeFocused = false }, - [Tab.NewGame] = new GUIFrame(new RectTransform(relativeSize * new Vector2(1.0f, 1.15f), GUI.Canvas, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }), - [Tab.LoadGame] = new GUIFrame(new RectTransform(relativeSize, GUI.Canvas, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }) + [Tab.NewGame] = new GUIFrame(new RectTransform(relativeSize * new Vector2(1.0f, 1.15f), Frame.RectTransform, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }), + [Tab.LoadGame] = new GUIFrame(new RectTransform(relativeSize, Frame.RectTransform, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }) }; CreateCampaignSetupUI(); var hostServerScale = new Vector2(0.7f, 1.2f); menuTabs[Tab.HostServer] = new GUIFrame(new RectTransform( - Vector2.Multiply(relativeSize, hostServerScale), GUI.Canvas, anchor, pivot, minSize.Multiply(hostServerScale), maxSize.Multiply(hostServerScale)) + Vector2.Multiply(relativeSize, hostServerScale), Frame.RectTransform, anchor, pivot, minSize.Multiply(hostServerScale), maxSize.Multiply(hostServerScale)) { RelativeOffset = relativeOffset }); CreateHostServerFields(); //---------------------------------------------------------------------- - menuTabs[Tab.Tutorials] = new GUIFrame(new RectTransform(relativeSize, GUI.Canvas, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }); + menuTabs[Tab.Tutorials] = new GUIFrame(new RectTransform(relativeSize, Frame.RectTransform, anchor, pivot, minSize, maxSize) { RelativeOffset = relativeOffset }); CreateTutorialTab(); this.game = game; - menuTabs[Tab.Credits] = new GUIFrame(new RectTransform(Vector2.One, GUI.Canvas, Anchor.Center), style: null) + menuTabs[Tab.Credits] = new GUIFrame(new RectTransform(Vector2.One, Frame.RectTransform, Anchor.Center), style: null) { CanBeFocused = false }; - new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, menuTabs[Tab.Credits].RectTransform, Anchor.Center), style: "GUIBackgroundBlocker") + var blockerFrame = new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, menuTabs[Tab.Credits].RectTransform, Anchor.Center), style: "GUIBackgroundBlocker") { CanBeFocused = false }; + blockerFrame.RectTransform.RelativeOffset = GUI.IsUltrawide ? Vector2.Zero : new Vector2(0.05f, 0.0f); var creditsContainer = new GUIFrame(new RectTransform(new Vector2(0.75f, 1.5f), menuTabs[Tab.Credits].RectTransform, Anchor.CenterRight), style: "OuterGlow", color: Color.Black * 0.8f); creditsPlayer = new CreditsPlayer(new RectTransform(Vector2.One, creditsContainer.RectTransform), "Content/Texts/Credits.xml"); @@ -499,6 +567,7 @@ namespace Barotrauma }; SetMenuTabPositioning(); + SelectTab(Tab.Empty); } private void SetMenuTabPositioning() @@ -591,7 +660,7 @@ namespace Barotrauma public override void Select() { ResetModUpdateButton(); - + if (WorkshopItemsToUpdate.Any()) { while (WorkshopItemsToUpdate.TryDequeue(out ulong workshopId)) @@ -616,6 +685,8 @@ namespace Barotrauma versionMismatchWarning.Visible = GameMain.Version < ContentPackageManager.VanillaCorePackage.GameVersion; ResetButtonStates(null); + + Eos.EosAccount.ExecuteAfterLogin(AchievementManager.SyncBetweenPlatforms); } public override void Deselect() @@ -731,7 +802,7 @@ namespace Barotrauma case Tab.SubmarineEditor: CoroutineManager.StartCoroutine(SelectScreenWithWaitCursor(GameMain.SubEditorScreen)); break; - case Tab.SteamWorkshop: + case Tab.Mods: var settings = SettingsMenu.Create(menuTabs[Tab.Settings].RectTransform); settings.SelectTab(SettingsMenu.Tab.Mods); tab = Tab.Settings; @@ -747,6 +818,14 @@ namespace Barotrauma } selectedTab = tab; + leftTextFooterLayout.Visible = tab != Tab.Credits; + rightTextFooterLayout.Visible = tab != Tab.Credits; + + foreach (var tabFrame in menuTabs.Values) + { + tabFrame.Visible = false; + } + if (menuTabs.TryGetValue(selectedTab, out var visibleTab)) { visibleTab.Visible = true; } return true; } @@ -871,6 +950,17 @@ namespace Barotrauma tutorialSkipWarning.Buttons[1].OnClicked += proceedToTab(Tab.Tutorials); } + public override void AddToGUIUpdateList() + { + base.AddToGUIUpdateList(); + switch (selectedTab) + { + case Tab.NewGame: + campaignSetupUI.CharacterMenus?.ForEach(static m => m.AddToGUIUpdateList()); + break; + } + } + private void UpdateTutorialList() { foreach (GUITextBlock tutorialText in tutorialList.Content.Children) @@ -951,35 +1041,55 @@ namespace Barotrauma #endif } - string arguments = - "-name \"" + ToolBox.EscapeCharacters(name) + "\"" + - " -public " + isPublicBox.Selected.ToString() + - " -playstyle " + ((PlayStyle)playstyleBanner.UserData).ToString() + - " -banafterwrongpassword " + wrongPasswordBanBox.Selected.ToString() + - " -karmaenabled " + (!karmaBox.Selected).ToString() + - " -maxplayers " + maxPlayersBox.Text + - $" -language \"{(LanguageIdentifier)languageDropdown.SelectedData}\""; + var arguments = new List + { + "-name", name, + "-public", isPublicBox.Selected.ToString(), + "-playstyle", ((PlayStyle)playstyleBanner.UserData).ToString(), + "-banafterwrongpassword", wrongPasswordBanBox.Selected.ToString(), + "-karmaenabled", (!karmaBox.Selected).ToString(), + "-maxplayers", maxPlayersBox.Text, + "-language", languageDropdown.SelectedData.ToString() + }; if (!string.IsNullOrWhiteSpace(passwordBox.Text)) { - arguments += " -password \"" + ToolBox.EscapeCharacters(passwordBox.Text) + "\""; + arguments.Add("-password"); + arguments.Add(passwordBox.Text); } else { - arguments += " -nopassword"; + arguments.Add("-nopassword"); } - if (SteamManager.GetSteamId().TryUnwrap(out var steamId1)) + var puids = EosInterface.IdQueries.GetLoggedInPuids(); + + var endpoints = new List(); + if (SteamManager.GetSteamId().TryUnwrap(out var steamId)) { - arguments += " -steamid " + steamId1.Value; + endpoints.Add(new SteamP2PEndpoint(steamId)); + } + if (puids.Length > 0) + { + endpoints.Add(new EosP2PEndpoint(puids[0])); + } + if (endpoints.Count == 0) + { + endpoints.Add(new LidgrenEndpoint(IPAddress.Loopback, NetConfig.DefaultPort)); + } + + if (endpoints.First() is P2PEndpoint firstEndpoint) + { + arguments.Add("-endpoint"); + arguments.Add(firstEndpoint.StringRepresentation); } int ownerKey = Math.Max(CryptoRandom.Instance.Next(), 1); - arguments += " -ownerkey " + ownerKey; - + arguments.Add("-ownerkey"); + arguments.Add(ownerKey.ToString()); + var processInfo = new ProcessStartInfo { FileName = fileName, - Arguments = arguments, WorkingDirectory = Directory.GetCurrentDirectory(), #if !DEBUG CreateNoWindow = true, @@ -987,16 +1097,15 @@ namespace Barotrauma WindowStyle = ProcessWindowStyle.Hidden #endif }; + arguments.ForEach(processInfo.ArgumentList.Add); ChildServerRelay.Start(processInfo); Thread.Sleep(1000); //wait until the server is ready before connecting GameMain.Client = new GameClient(MultiplayerPreferences.Instance.PlayerName.FallbackNullOrEmpty( SteamManager.GetUsername().FallbackNullOrEmpty(name)), - SteamManager.GetSteamId().TryUnwrap(out var steamId) - ? new SteamP2PEndpoint(steamId) - : (Endpoint)new LidgrenEndpoint(IPAddress.Loopback, NetConfig.DefaultPort), + endpoints.ToImmutableArray(), name, - Option.Some(ownerKey)); + Option.Some(ownerKey)); } catch (Exception e) { @@ -1010,21 +1119,6 @@ namespace Barotrauma return true; } - public override void AddToGUIUpdateList() - { - Frame.AddToGUIUpdateList(); - if (selectedTab < Tab.Empty && menuTabs.TryGetValue(selectedTab, out GUIFrame tab) && tab != null) - { - tab.AddToGUIUpdateList(); - switch (selectedTab) - { - case Tab.NewGame: - campaignSetupUI.CharacterMenus?.ForEach(m => m.AddToGUIUpdateList()); - break; - } - } - } - private void UpdateOutOfDateWorkshopItemCount() { if (DateTime.Now < modUpdateStatus.WhenToRefresh) { return; } @@ -1059,16 +1153,16 @@ namespace Barotrauma modUpdateStatus = (DateTime.Now + ModUpdateInterval, count); } + private static bool CanHostServer() + => EosInterface.IdQueries.IsLoggedIntoEosConnect + || SteamManager.IsInitialized + || AssemblyInfo.CurrentConfiguration == AssemblyInfo.Configuration.Debug; + public override void Update(double deltaTime) { -#if DEBUG - hostServerButton.Enabled = true; -#else - if (GameSettings.CurrentConfig.UseSteamMatchmaking) - { - hostServerButton.Enabled = SteamManager.IsInitialized; - } -#endif + hostServerButton.Enabled = CanHostServer(); + + gameAnalyticsStatusText.Text = TextManager.Get($"GameAnalyticsStatus.{GameAnalyticsManager.UserConsented}"); UpdateOutOfDateWorkshopItemCount(); modUpdatesButton.Visible = modUpdateStatus.Count > 0; @@ -1125,13 +1219,6 @@ namespace Barotrauma } } - readonly LocalizedString[] legalCrap = new LocalizedString[] - { - TextManager.Get("privacypolicy").Fallback("Privacy policy"), - "© " + DateTime.Now.Year + " Undertow Games & FakeFish. All rights reserved.", - "© " + DateTime.Now.Year + " Daedalic Entertainment GmbH. The Daedalic logo is a trademark of Daedalic Entertainment GmbH, Germany. All rights reserved." - }; - public override void Draw(double deltaTime, GraphicsDevice graphics, SpriteBatch spriteBatch) { spriteBatch.Begin(SpriteSortMode.Deferred, null, GUI.SamplerState, null, GameMain.ScissorTestEnable); @@ -1140,42 +1227,6 @@ namespace Barotrauma GUI.Draw(Cam, spriteBatch); - if (selectedTab != Tab.Credits) - { -#if !UNSTABLE - string versionString = "Barotrauma v" + GameMain.Version + " (" + AssemblyInfo.BuildString + ", branch " + AssemblyInfo.GitBranch + ", revision " + AssemblyInfo.GitRevision + ")"; - GUIStyle.SmallFont.DrawString(spriteBatch, versionString, new Vector2(HUDLayoutSettings.Padding, GameMain.GraphicsHeight - GUIStyle.SmallFont.MeasureString(versionString).Y - HUDLayoutSettings.Padding * 0.75f), Color.White * 0.7f); -#endif - LocalizedString gameAnalyticsStatus = TextManager.Get($"GameAnalyticsStatus.{GameAnalyticsManager.UserConsented}"); - Vector2 textSize = GUIStyle.SmallFont.MeasureString(gameAnalyticsStatus).ToPoint().ToVector2(); - GUIStyle.SmallFont.DrawString(spriteBatch, gameAnalyticsStatus, new Vector2(HUDLayoutSettings.Padding, GameMain.GraphicsHeight - GUIStyle.SmallFont.LineHeight * 2 - HUDLayoutSettings.Padding * 0.75f), Color.White * 0.7f); - - - Vector2 textPos = new Vector2(GameMain.GraphicsWidth - HUDLayoutSettings.Padding, GameMain.GraphicsHeight - HUDLayoutSettings.Padding * 0.75f); - for (int i = legalCrap.Length - 1; i >= 0; i--) - { - textSize = GUIStyle.SmallFont.MeasureString(legalCrap[i]) - .ToPoint().ToVector2(); - bool mouseOn = i == 0 && - PlayerInput.MousePosition.X > textPos.X - textSize.X && PlayerInput.MousePosition.X < textPos.X && - PlayerInput.MousePosition.Y > textPos.Y - textSize.Y && PlayerInput.MousePosition.Y < textPos.Y; - - GUIStyle.SmallFont.DrawString(spriteBatch, - legalCrap[i], textPos - textSize, - mouseOn ? Color.White : Color.White * 0.7f); - - if (i == 0) - { - GUI.DrawLine(spriteBatch, textPos, textPos - Vector2.UnitX * textSize.X, mouseOn ? Color.White : Color.White * 0.7f); - if (mouseOn && PlayerInput.PrimaryMouseButtonClicked() && GUI.MouseOn == null) - { - GameMain.ShowOpenUrlInWebBrowserPrompt("http://privacypolicy.daedalic.com"); - } - } - textPos.Y -= textSize.Y; - } - } - spriteBatch.End(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs index f35e8c9d7..af8541d80 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/NetLobbyScreen.cs @@ -2320,12 +2320,12 @@ namespace Barotrauma List options = new List(); - if (client.AccountId.TryUnwrap(out var accountId) && accountId is SteamId steamId) + if (client.AccountId.TryUnwrap(out var accountId)) { - options.Add(new ContextMenuOption("ViewSteamProfile", isEnabled: hasAccountId, onSelected: () => - { - SteamManager.OverlayProfile(steamId); - })); + options.Add(new ContextMenuOption(accountId.ViewProfileLabel(), isEnabled: hasAccountId, onSelected: () => + { + accountId.OpenProfile(); + })); } options.Add(new ContextMenuOption("ModerationMenu.ManagePlayer", isEnabled: true, onSelected: () => @@ -2702,17 +2702,17 @@ namespace Barotrauma } } - if (selectedClient.AccountId.TryUnwrap(out var accountId) && accountId is SteamId steamId && Steam.SteamManager.IsInitialized) + if (selectedClient.AccountId.TryUnwrap(out var accountId)) { var viewSteamProfileButton = new GUIButton(new RectTransform(new Vector2(0.3f, 1.0f), headerContainer.RectTransform, Anchor.TopCenter) { MaxSize = new Point(int.MaxValue, (int)(40 * GUI.Scale)) }, - TextManager.Get("ViewSteamProfile")) + accountId.ViewProfileLabel()) { UserData = selectedClient }; viewSteamProfileButton.TextBlock.AutoScaleHorizontal = true; viewSteamProfileButton.OnClicked = (bt, userdata) => { - SteamManager.OverlayProfile(steamId); + accountId.OpenProfile(); return true; }; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/Screen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/Screen.cs index cdce5489e..7421e6f76 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/Screen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/Screen.cs @@ -7,25 +7,20 @@ namespace Barotrauma { abstract partial class Screen { - private GUIFrame frame; - public GUIFrame Frame - { - get - { - if (frame == null) - { - frame = new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, GUI.Canvas), style: null) - { - CanBeFocused = false - }; + public readonly GUIFrame Frame; - } - return frame; - } + protected Screen() + { + Frame = new GUIFrame(new RectTransform(Vector2.One, GUI.Canvas), style: null) + { + CanBeFocused = false + }; } /// - /// By default, creates a new frame for the screen and adds all elements to the gui update list. + /// By default, submits the screen's main GUIFrame and, + /// if requested upon construction, the social drawer, + /// to the GUI update list. /// public virtual void AddToGUIUpdateList() { @@ -68,9 +63,7 @@ namespace Barotrauma public virtual void Release() { - if (frame is null) { return; } - frame.RectTransform.Parent = null; - frame = null; + Frame.RectTransform.Parent = null; } } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs index d48c4bc39..a435e37a7 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/ServerListScreen/ServerListScreen.cs @@ -7,7 +7,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; using System.Xml.Linq; +using Barotrauma.Steam; namespace Barotrauma { @@ -44,70 +46,6 @@ namespace Barotrauma Disabled } - //friends list - public sealed class FriendInfo - { - public string Name; - - public readonly AccountId Id; - - public enum Status - { - Offline, - NotPlaying, - PlayingAnotherGame, - PlayingBarotrauma - } - - public readonly Status CurrentStatus; - - public string ServerName; - - public Option ConnectCommand; - public Option Avatar; - - public bool IsInServer - => CurrentStatus == Status.PlayingBarotrauma && ConnectCommand.IsSome(); - - public bool IsPlayingBarotrauma - => CurrentStatus == Status.PlayingBarotrauma; - - public bool PlayingAnotherGame - => CurrentStatus == Status.PlayingAnotherGame; - - public bool IsOnline - => CurrentStatus != Status.Offline; - - public LocalizedString StatusText - => CurrentStatus switch - { - Status.Offline => "", - _ when ConnectCommand.IsSome() - => TextManager.GetWithVariable("FriendPlayingOnServer", "[servername]", ServerName), - _ => TextManager.Get($"Friend{CurrentStatus}") - }; - - public FriendInfo(string name, AccountId id, Status status) - { - Name = name; - Id = id; - CurrentStatus = status; - ConnectCommand = Option.None(); - Avatar = Option.None(); - } - } - - private GUILayoutGroup friendsButtonHolder; - - private GUIButton friendsDropdownButton; - private GUIListBox friendsDropdown; - - private readonly FriendProvider friendProvider = new SteamFriendProvider(); - - private List friendsList; - private GUIFrame friendPopup; - private double friendsListUpdateTime; - public enum TabEnum { All, @@ -115,7 +53,7 @@ namespace Barotrauma Recent } - public struct Tab + public readonly struct Tab { public readonly string Storage; public readonly GUIButton Button; @@ -127,7 +65,7 @@ namespace Barotrauma { Storage = storage; servers = new List(); - Button = new GUIButton(new RectTransform(new Vector2(0.2f, 1.0f), tabber.RectTransform), + Button = new GUIButton(new RectTransform(new Vector2(0.33f, 1.0f), tabber.RectTransform), TextManager.Get($"ServerListTab.{tabEnum}"), style: "GUITabButton") { OnClicked = (_,__) => @@ -187,8 +125,7 @@ namespace Barotrauma } } - private readonly ServerProvider serverProvider - = new CompositeServerProvider(new SteamDedicatedServerProvider(), new SteamP2PServerProvider()); + private ServerProvider serverProvider = null; public GUITextBox ClientNameBox { get; private set; } @@ -257,16 +194,16 @@ namespace Barotrauma private bool sortedAscending = true; private const float sidebarWidth = 0.2f; - public ServerListScreen() + public ServerListScreen() : base() { selectedServer = Option.None(); GameMain.Instance.ResolutionChanged += CreateUI; CreateUI(); } - private string GetDefaultUserName() + private static Task GetDefaultUserName() { - return friendProvider.GetUserName(); + return new CompositeFriendProvider(new SteamFriendProvider(), new EpicFriendProvider()).GetSelfUserName(); } private void AddTernaryFilter(RectTransform parent, float elementHeight, Identifier tag, Action valueSetter) @@ -331,13 +268,32 @@ namespace Barotrauma var topRow = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.15f), paddedFrame.RectTransform)) { Stretch = true }; - var title = new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.33f), topRow.RectTransform), TextManager.Get("JoinServer"), font: GUIStyle.LargeFont) + var titleContainer = new GUILayoutGroup(new RectTransform(new Vector2(0.995f, 0.33f), topRow.RectTransform), isHorizontal: true) { Stretch = true }; + + var title = new GUITextBlock(new RectTransform(new Vector2(1.0f, 1.0f), titleContainer.RectTransform), TextManager.Get("JoinServer"), font: GUIStyle.LargeFont) { Padding = Vector4.Zero, ForceUpperCase = ForceUpperCase.Yes, AutoScaleHorizontal = true }; + var friendsButton = new GUIButton( + new RectTransform(Vector2.One * 0.9f, titleContainer.RectTransform, scaleBasis: ScaleBasis.BothHeight), + style: "FriendsButton") + { + OnClicked = (_, _) => + { + if (SocialOverlay.Instance is { } socialOverlay) { socialOverlay.IsOpen = true; } + return false; + }, + ToolTip = TextManager.GetWithVariable("SocialOverlayShortcutHint", "[shortcut]", SocialOverlay.ShortcutBindText) + }; + new GUIFrame(new RectTransform(Vector2.One, friendsButton.RectTransform, Anchor.Center), + style: "FriendsButtonIcon") + { + CanBeFocused = false + }; + var infoHolder = new GUILayoutGroup(new RectTransform(new Vector2(1.0f, 0.33f), topRow.RectTransform), isHorizontal: true, Anchor.BottomLeft) { RelativeSpacing = 0.01f, Stretch = false }; var clientNameHolder = new GUILayoutGroup(new RectTransform(new Vector2(sidebarWidth, 1.0f), infoHolder.RectTransform)) { RelativeSpacing = 0.05f }; @@ -352,7 +308,13 @@ namespace Barotrauma if (string.IsNullOrEmpty(ClientNameBox.Text)) { - ClientNameBox.Text = GetDefaultUserName(); + TaskPool.Add("GetDefaultUserName", + GetDefaultUserName(), + t => + { + if (!t.TryGetResult(out string name)) { return; } + if (ClientNameBox.Text.IsNullOrEmpty()) { ClientNameBox.Text = name; } + }); } ClientNameBox.OnTextChanged += (textbox, text) => { @@ -366,14 +328,6 @@ namespace Barotrauma tabs[TabEnum.Favorites] = new Tab(TabEnum.Favorites, this, tabButtonHolder, "Data/favoriteservers.xml"); tabs[TabEnum.Recent] = new Tab(TabEnum.Recent, this, tabButtonHolder, "Data/recentservers.xml"); - var friendsButtonFrame = new GUIFrame(new RectTransform(new Vector2(0.31f, 2.0f), tabButtonHolder.RectTransform, Anchor.BottomRight), style: "InnerFrame") - { - IgnoreLayoutGroups = true - }; - - friendsButtonHolder = new GUILayoutGroup(new RectTransform(new Vector2(0.98f, 0.9f), friendsButtonFrame.RectTransform, Anchor.Center), childAnchor: Anchor.TopLeft) { RelativeSpacing = 0.01f, IsHorizontal = true }; - friendsList = new List(); - //------------------------------------------------------------------------------------- // Bottom row //------------------------------------------------------------------------------------- @@ -740,7 +694,7 @@ namespace Barotrauma { if (selectedServer.TryUnwrap(out var serverInfo)) { - JoinServer(serverInfo.Endpoint, serverInfo.ServerName); + JoinServer(serverInfo.Endpoints, serverInfo.ServerName); } return true; }, @@ -778,7 +732,7 @@ namespace Barotrauma { GUIComponent existingElement = serverList.Content.FindChild(d => d.UserData is ServerInfo existingServerInfo && - existingServerInfo.Endpoint == serverInfo.Endpoint); + existingServerInfo.Endpoints.Any(serverInfo.Endpoints.Contains)); if (existingElement == null) { AddToServerList(serverInfo); @@ -791,7 +745,7 @@ namespace Barotrauma public void AddToRecentServers(ServerInfo info) { - if (info.Endpoint.Address.IsLocalHost) { return; } + if (info.Endpoints.First().Address.IsLocalHost) { return; } tabs[TabEnum.Recent].AddOrUpdate(info); tabs[TabEnum.Recent].Save(); } @@ -924,7 +878,32 @@ namespace Barotrauma public override void Select() { base.Select(); - + + if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + if (SteamManager.IsInitialized) + { + serverProvider = new CompositeServerProvider( + new EosServerProvider(), + new SteamDedicatedServerProvider(), + new SteamP2PServerProvider()); + } + else + { + serverProvider = new EosServerProvider(); + } + } + else if (SteamManager.IsInitialized) + { + serverProvider = new CompositeServerProvider( + new SteamDedicatedServerProvider(), + new SteamP2PServerProvider()); + } + else + { + serverProvider = null; + } + Steamworks.SteamMatchmaking.ResetActions(); selectedTab = TabEnum.All; @@ -957,6 +936,7 @@ namespace Barotrauma public override void Deselect() { base.Deselect(); + serverProvider?.Cancel(); GameSettings.SaveCurrentConfig(); } @@ -964,21 +944,9 @@ namespace Barotrauma { base.Update(deltaTime); - UpdateFriendsList(); panelAnimator?.Update(); scanServersButton.Enabled = (DateTime.Now - lastRefreshTime) >= AllowedRefreshInterval; - - if (PlayerInput.PrimaryMouseButtonClicked()) - { - friendPopup = null; - if (friendsDropdown != null && friendsDropdownButton != null && - !friendsDropdown.Rect.Contains(PlayerInput.MousePosition) && - !friendsDropdownButton.Rect.Contains(PlayerInput.MousePosition)) - { - friendsDropdown.Visible = false; - } - } } public void FilterServers() @@ -1113,7 +1081,8 @@ namespace Barotrauma RelativeSpacing = 0.05f }; - new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), content.RectTransform), TextManager.Get("ServerEndpoint"), textAlignment: Alignment.Center); + new GUITextBlock(new RectTransform(new Vector2(1.0f, 0.5f), content.RectTransform), + SteamManager.IsInitialized ? TextManager.Get("ServerEndpoint") : TextManager.Get("ServerIP"), textAlignment: Alignment.Center); var endpointBox = new GUITextBox(new RectTransform(new Vector2(1.0f, 0.5f), content.RectTransform)); content.RectTransform.NonScaledSize = new Point(content.Rect.Width, (int)(content.RectTransform.Children.Sum(c => c.Rect.Height))); @@ -1126,11 +1095,18 @@ namespace Barotrauma { if (Endpoint.Parse(endpointBox.Text).TryUnwrap(out var endpoint)) { - JoinServer(endpoint, ""); + if (endpoint is SteamP2PEndpoint && !SteamManager.IsInitialized) + { + new GUIMessageBox(TextManager.Get("error"), TextManager.Get("CannotJoinSteamServer.SteamNotInitialized")); + } + else + { + JoinServer(endpoint.ToEnumerable().ToImmutableArray(), ""); + } } else if (LidgrenEndpoint.ParseFromWithHostNameCheck(endpointBox.Text, tryParseHostName: true).TryUnwrap(out var lidgrenEndpoint)) { - JoinServer(lidgrenEndpoint, ""); + JoinServer(((Endpoint)lidgrenEndpoint).ToEnumerable().ToImmutableArray(), ""); } else { @@ -1171,8 +1147,6 @@ namespace Barotrauma selectedTab = TabEnum.Favorites; FilterServers(); - #warning Interface with server providers to get up-to-date info on the given server - msgBox.Close(); return false; }; @@ -1187,222 +1161,6 @@ namespace Barotrauma }; } - private bool JoinFriend(GUIButton button, object userdata) - { - if (!(userdata is FriendInfo { IsInServer: true } info)) { return false; } - - GameMain.Instance.ConnectCommand = info.ConnectCommand; - return false; - } - - private bool OpenFriendPopup(GUIButton button, object userdata) - { - if (!(userdata is FriendInfo { IsInServer: true } info)) { return false; } - - if (info.IsInServer - && info.ConnectCommand.TryUnwrap(out var command) - && command.EndpointOrLobby.TryGet(out ConnectCommand.NameAndEndpoint nameAndEndpoint)) - { - const int framePadding = 5; - - friendPopup = new GUIFrame(new RectTransform(Vector2.One, GUI.Canvas)); - - var serverNameText = new GUITextBlock(new RectTransform(new Vector2(0.7f, 1.0f), friendPopup.RectTransform, Anchor.CenterLeft), nameAndEndpoint.ServerName ?? "[Unnamed]"); - serverNameText.RectTransform.AbsoluteOffset = new Point(framePadding, 0); - - var joinButton = new GUIButton(new RectTransform(new Vector2(0.3f, 1.0f), friendPopup.RectTransform, Anchor.CenterRight), TextManager.Get("ServerListJoin")) - { - UserData = info - }; - joinButton.OnClicked = JoinFriend; - joinButton.RectTransform.AbsoluteOffset = new Point(framePadding, 0); - - Point joinButtonTextSize = joinButton.Font.MeasureString(joinButton.Text).ToPoint(); - int joinButtonHeight = joinButton.RectTransform.NonScaledSize.Y; - int totalAdditionalTextPadding = (joinButtonHeight - joinButtonTextSize.Y); - - // Make the final button sized so that the space between the text and the edges in the X direction is the same as the Y direction. - Point finalButtonSize = new Point(joinButtonTextSize.X + totalAdditionalTextPadding, joinButtonHeight); - - // Add padding to the server name to match the padding on the button text. - serverNameText.Padding = new Vector4(totalAdditionalTextPadding / 2); - - // Get the dimensions of the text we want to show, plus the extra padding we added. - Point serverNameSize = serverNameText.Font.MeasureString(serverNameText.Text).ToPoint() + new Point(totalAdditionalTextPadding, totalAdditionalTextPadding); - - // Now determine how large the parent frame has to be to exactly fit our two controls. - Point frameDims = new Point(serverNameSize.X + finalButtonSize.X + framePadding*2, Math.Max(serverNameSize.Y, finalButtonSize.Y) + framePadding * 2); - - var popupPos = PlayerInput.MousePosition.ToPoint(); - if(popupPos.X+frameDims.X > GUI.Canvas.NonScaledSize.X) - { - // Prevent the Join button from going off the end of the screen if the server name is long or we click a user towards the edge. - popupPos.X = GUI.Canvas.NonScaledSize.X - frameDims.X; - } - - // Apply the size and position changes. - friendPopup.RectTransform.NonScaledSize = frameDims; - friendPopup.RectTransform.RelativeOffset = Vector2.Zero; - friendPopup.RectTransform.AbsoluteOffset = popupPos; - - joinButton.RectTransform.NonScaledSize = finalButtonSize; - - friendPopup.RectTransform.RecalculateChildren(true); - friendPopup.RectTransform.SetPosition(Anchor.TopLeft); - } - - return false; - } - - public enum AvatarSize - { - Small, - Medium, - Large - } - - private void UpdateFriendsList() - { - if (friendsListUpdateTime > Timing.TotalTime) { return; } - friendsListUpdateTime = Timing.TotalTime + 5.0; - - float prevDropdownScroll = friendsDropdown?.ScrollBar.BarScrollValue ?? 0.0f; - - friendsDropdown ??= new GUIListBox(new RectTransform(Vector2.One, GUI.Canvas)) - { - OutlineColor = Color.Black, - Visible = false - }; - friendsDropdown.ClearChildren(); - - var avatarSize = friendsButtonHolder.RectTransform.Rect.Height switch - { - var h when h <= 24 => AvatarSize.Small, - var h when h <= 48 => AvatarSize.Medium, - _ => AvatarSize.Large - }; - - FriendInfo[] friends = friendProvider.RetrieveFriends(); - - foreach (var friend in friends) - { - int existingIndex = friendsList.FindIndex(f => f.Id == friend.Id); - if (existingIndex >= 0) - { - friend.Avatar = friend.Avatar.Fallback(friendsList[existingIndex].Avatar); - } - - if (friend.Avatar.IsNone()) - { - friendProvider.RetrieveAvatar(friend, avatarSize); - } - } - - friendsList.Clear(); friendsList.AddRange(friends.OrderByDescending(f => f.CurrentStatus)); - - friendsButtonHolder.ClearChildren(); - - if (friendsList.Count > 0) - { - friendsDropdownButton = new GUIButton(new RectTransform(Vector2.One, friendsButtonHolder.RectTransform, Anchor.BottomRight, Pivot.BottomRight, scaleBasis: ScaleBasis.BothHeight), "\u2022 \u2022 \u2022", style: "GUIButtonFriendsDropdown") - { - OnClicked = (button, udt) => - { - friendsDropdown.RectTransform.NonScaledSize = new Point(friendsButtonHolder.Rect.Height * 5 * 166 / 100, friendsButtonHolder.Rect.Height * 4 * 166 / 100); - friendsDropdown.RectTransform.AbsoluteOffset = new Point(friendsButtonHolder.Rect.X, friendsButtonHolder.Rect.Bottom); - friendsDropdown.RectTransform.RecalculateChildren(true); - friendsDropdown.Visible = !friendsDropdown.Visible; - return false; - } - }; - } - else - { - friendsDropdownButton = null; - friendsDropdown.Visible = false; - } - - for (int i = 0; i < friendsList.Count; i++) - { - var friend = friendsList[i]; - - if (i < 5) - { - string style = friend.IsPlayingBarotrauma - ? "GUIButtonFriendPlaying" - : "GUIButtonFriendNotPlaying"; - - var guiButton = new GUIButton(new RectTransform(Vector2.One, friendsButtonHolder.RectTransform, scaleBasis: ScaleBasis.BothHeight), style: style) - { - UserData = friend, - OnClicked = OpenFriendPopup - }; - guiButton.ToolTip = friend.Name + "\n" + friend.StatusText; - - if (friend.Avatar.TryUnwrap(out Sprite sprite)) - { - new GUICustomComponent(new RectTransform(Vector2.One, guiButton.RectTransform, Anchor.Center), - onDraw: (sb, component) => - { - var destinationRect = component.Rect; - destinationRect.Inflate(-GUI.IntScale(4), -GUI.IntScale(4)); - sb.Draw(sprite.Texture, destinationRect, Color.White); - - if (!GUI.IsMouseOn(guiButton)) - { - return; - } - - sb.End(); - sb.Begin( - SpriteSortMode.Deferred, - blendState: BlendState.Additive, - samplerState: GUI.SamplerState, - rasterizerState: GameMain.ScissorTestEnable); - sb.Draw(sprite.Texture, destinationRect, Color.White * 0.5f); - sb.End(); - sb.Begin( - SpriteSortMode.Deferred, - samplerState: GUI.SamplerState, - rasterizerState: GameMain.ScissorTestEnable); - }) { CanBeFocused = false }; - } - } - - var friendFrame = new GUIFrame(new RectTransform(new Vector2(1.0f, 0.167f), friendsDropdown.Content.RectTransform), style: "GUIFrameFriendsDropdown"); - if (friend.Avatar.TryUnwrap(out var avatar)) - { - GUIImage guiImage = - new GUIImage( - new RectTransform(Vector2.One * 0.9f, friendFrame.RectTransform, Anchor.CenterLeft, - scaleBasis: ScaleBasis.BothHeight) { RelativeOffset = new Vector2(0.02f, 0.02f) }, - avatar, null, true); - } - - var textBlock = new GUITextBlock(new RectTransform(Vector2.One * 0.8f, friendFrame.RectTransform, Anchor.CenterLeft, scaleBasis: ScaleBasis.BothHeight) { RelativeOffset = new Vector2(1.0f / 7.7f, 0.0f) }, friend.Name + "\n" + friend.StatusText) - { - Font = GUIStyle.SmallFont - }; - if (friend.IsPlayingBarotrauma) { textBlock.TextColor = GUIStyle.Green; } - if (friend.PlayingAnotherGame) { textBlock.TextColor = GUIStyle.Blue; } - - if (friend.IsInServer) - { - var joinButton = new GUIButton(new RectTransform(new Vector2(0.25f, 0.6f), friendFrame.RectTransform, Anchor.CenterRight) { RelativeOffset = new Vector2(0.05f, 0.0f) }, TextManager.Get("ServerListJoin"), style: "GUIButtonJoinFriend") - { - UserData = friend, - OnClicked = JoinFriend - }; - } - } - - friendsDropdown.RectTransform.NonScaledSize = new Point(friendsButtonHolder.Rect.Height * 5 * 166 / 100, friendsButtonHolder.Rect.Height * 4 * 166 / 100); - friendsDropdown.RectTransform.AbsoluteOffset = new Point(friendsButtonHolder.Rect.X, friendsButtonHolder.Rect.Bottom); - friendsDropdown.RectTransform.RecalculateChildren(true); - - friendsDropdown.ScrollBar.BarScrollValue = prevDropdownScroll; - } - private void RemoveMsgFromServerList() { serverList.Content.Children @@ -1429,7 +1187,7 @@ namespace Barotrauma private void RefreshServers() { lastRefreshTime = DateTime.Now; - serverProvider.Cancel(); + serverProvider?.Cancel(); currentServerDataRecvCallbackObj = null; PingUtils.QueryPingData(); @@ -1462,7 +1220,7 @@ namespace Barotrauma } var (onServerDataReceived, onQueryCompleted) = MakeServerQueryCallbacks(); - serverProvider.RetrieveServers(onServerDataReceived, onQueryCompleted); + serverProvider?.RetrieveServers(onServerDataReceived, onQueryCompleted); } private GUIComponent FindFrameMatchingServerInfo(ServerInfo serverInfo) @@ -1474,7 +1232,7 @@ namespace Barotrauma #if DEBUG if (serverList.Content.Children.Count(matches) > 1) { - DebugConsole.ThrowError($"There are several entries in the server list for endpoint {serverInfo.Endpoint}"); + DebugConsole.ThrowError($"There are several entries in the server list for endpoints {string.Join(", ", serverInfo.Endpoints)}"); } #endif @@ -1482,7 +1240,7 @@ namespace Barotrauma } private object currentServerDataRecvCallbackObj = null; - private (Action OnServerDataReceived, Action OnQueryCompleted) MakeServerQueryCallbacks() + private (Action OnServerDataReceived, Action OnQueryCompleted) MakeServerQueryCallbacks() { var uniqueObject = new object(); currentServerDataRecvCallbackObj = uniqueObject; @@ -1497,10 +1255,21 @@ namespace Barotrauma } return ( - serverInfo => + (serverInfo, serverProvider) => { if (!shouldRunCallback()) { return; } + if (serverProvider is not EosServerProvider + && EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + if (serverInfo.EosCrossplay) + { + // EosServerProvider should get us this server, + // don't add it again + return; + } + } + if (selectedTab == TabEnum.All) { AddToServerList(serverInfo); @@ -1724,7 +1493,7 @@ namespace Barotrauma private static void ReportServer(ServerInfo info, IEnumerable reasons) { if (!reasons.Any()) { return; } - GameAnalyticsManager.AddErrorEvent(GameAnalyticsManager.ErrorSeverity.Info, $"[Spam] Reported server: Name: \"{info.ServerName}\", Message: \"{info.ServerMessage}\", Endpoint: \"{info.Endpoint.StringRepresentation}\". Reason: \"{string.Join(", ", reasons)}\"."); + GameAnalyticsManager.AddErrorEvent(GameAnalyticsManager.ErrorSeverity.Info, $"[Spam] Reported server: Name: \"{info.ServerName}\", Message: \"{info.ServerMessage}\", Endpoint: \"{info.Endpoints.First().StringRepresentation}\". Reason: \"{string.Join(", ", reasons)}\"."); } private void UpdateServerInfoUI(ServerInfo serverInfo) @@ -1784,7 +1553,7 @@ namespace Barotrauma var serverName = new GUITextBlock(columnRT(ColumnLabel.ServerListName), #if DEBUG - $"[{serverInfo.Endpoint.GetType().Name}] " + + $"[{serverInfo.Endpoints.First().GetType().Name}] " + #endif serverInfo.ServerName, style: "GUIServerListTextBox") { CanBeFocused = false }; @@ -1819,6 +1588,13 @@ namespace Barotrauma serverPingText.Text = ping.ToString(); serverPingText.TextColor = GetPingTextColor(ping); } + else if ((serverInfo.Endpoints.Length == 1 && serverInfo.Endpoints.First() is EosP2PEndpoint) + || (!SteamManager.IsInitialized && serverInfo.Endpoints.Any(e => e is P2PEndpoint))) + { + serverPingText.Text = "-"; + serverPingText.ToolTip = TextManager.Get("EosPingUnavailable"); + serverPingText.TextAlignment = Alignment.Center; + } else { serverPingText.Text = "?"; @@ -1954,7 +1730,7 @@ namespace Barotrauma } } - public void JoinServer(Endpoint endpoint, string serverName) + public void JoinServer(ImmutableArray endpoints, string serverName) { if (string.IsNullOrWhiteSpace(ClientNameBox.Text)) { @@ -1967,20 +1743,38 @@ namespace Barotrauma MultiplayerPreferences.Instance.PlayerName = ClientNameBox.Text; GameSettings.SaveCurrentConfig(); -#if !DEBUG - try + if (MultiplayerPreferences.Instance.PlayerName.IsNullOrEmpty()) { -#endif - GameMain.Client = new GameClient(MultiplayerPreferences.Instance.PlayerName.FallbackNullOrEmpty(GetDefaultUserName()), endpoint, serverName, Option.None()); -#if !DEBUG + TaskPool.Add("GetDefaultUserName", + GetDefaultUserName(), + t => + { + if (!t.TryGetResult(out string name)) { return; } + startClient(name); + }); } - catch (Exception e) + else { - DebugConsole.ThrowError("Failed to start the client", e); + startClient(MultiplayerPreferences.Instance.PlayerName); } + + void startClient(string name) + { +#if !DEBUG + try + { #endif + GameMain.Client = new GameClient(name, endpoints, serverName, Option.None); +#if !DEBUG + } + catch (Exception e) + { + DebugConsole.ThrowError("Failed to start the client", e); + } +#endif + } } - + private static Color GetPingTextColor(int ping) { if (ping < 0) { return Color.DarkRed; } @@ -2000,8 +1794,6 @@ namespace Barotrauma public override void AddToGUIUpdateList() { menu.AddToGUIUpdateList(); - friendPopup?.AddToGUIUpdateList(); - friendsDropdown?.AddToGUIUpdateList(); } public void StoreServerFilters() diff --git a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs index 768e1f4cc..cabd7e4fd 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Screens/SubEditorScreen.cs @@ -1554,7 +1554,6 @@ namespace Barotrauma autoSaveLabel?.Parent?.RemoveChild(autoSaveLabel); autoSaveLabel = null; -#if USE_STEAM if (editorSelectedTime.TryUnwrap(out DateTime selectedTime)) { TimeSpan timeInEditor = DateTime.Now - selectedTime; @@ -1568,11 +1567,10 @@ namespace Barotrauma } else { - SteamAchievementManager.IncrementStat("hoursineditor".ToIdentifier(), (float)timeInEditor.TotalHours); + AchievementManager.IncrementStat(AchievementStat.HoursInEditor, (float)timeInEditor.TotalHours); editorSelectedTime = Option.None(); } } -#endif GUI.ForceMouseOn(null); @@ -4173,7 +4171,7 @@ namespace Barotrauma Rectangle newColorRect = new Rectangle(rect.Location, areaSize); Rectangle oldColorRect = new Rectangle(new Point(newColorRect.Left, newColorRect.Bottom), areaSize); - GUI.DrawRectangle(batch, newColorRect, ToolBox.HSVToRGB(colorPicker.SelectedHue, colorPicker.SelectedSaturation, colorPicker.SelectedValue), isFilled: true); + GUI.DrawRectangle(batch, newColorRect, ToolBoxCore.HSVToRGB(colorPicker.SelectedHue, colorPicker.SelectedSaturation, colorPicker.SelectedValue), isFilled: true); GUI.DrawRectangle(batch, oldColorRect, originalColor, isFilled: true); GUI.DrawRectangle(batch, rect, Color.Black, isFilled: false); }); @@ -4293,7 +4291,7 @@ namespace Barotrauma setValues = true; } - Color color = ToolBox.HSVToRGB(colorPicker.SelectedHue, colorPicker.SelectedSaturation, colorPicker.SelectedValue); + Color color = ToolBoxCore.HSVToRGB(colorPicker.SelectedHue, colorPicker.SelectedSaturation, colorPicker.SelectedValue); foreach (var (e, origColor, prop) in entities) { if (e is MapEntity { Removed: true }) { continue; } @@ -4327,7 +4325,7 @@ namespace Barotrauma void SetHex(Vector3 hsv) { - Color hexColor = ToolBox.HSVToRGB(hsv.X, hsv.Y, hsv.Z); + Color hexColor = ToolBoxCore.HSVToRGB(hsv.X, hsv.Y, hsv.Z); hexValueBox!.Text = ColorToHex(hexColor); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs index 24d40dd72..f8c7eacf5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Settings/SettingsMenu.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.Linq; +using Barotrauma.Eos; using Barotrauma.Extensions; using Barotrauma.Networking; using Barotrauma.Steam; @@ -14,7 +15,7 @@ using OpenAL; namespace Barotrauma { - class SettingsMenu + sealed class SettingsMenu { public static SettingsMenu? Instance { get; private set; } @@ -682,22 +683,22 @@ namespace Barotrauma private void CreateGameplayTab() { GUIFrame content = CreateNewContentFrame(Tab.Gameplay); - - GUILayoutGroup layout = CreateCenterLayout(content); + + var (left, right) = CreateSidebars(content); var languages = TextManager.AvailableLanguages .OrderBy(l => TextManager.GetTranslatedLanguageName(l).ToIdentifier()) .ToArray(); - Label(layout, TextManager.Get("Language"), GUIStyle.SubHeadingFont); - Dropdown(layout, v => TextManager.GetTranslatedLanguageName(v), null, languages, unsavedConfig.Language, v => unsavedConfig.Language = v); - Spacer(layout); - - Tickbox(layout, TextManager.Get("PauseOnFocusLost"), TextManager.Get("PauseOnFocusLostTooltip"), unsavedConfig.PauseOnFocusLost, v => unsavedConfig.PauseOnFocusLost = v); - Spacer(layout); - - Tickbox(layout, TextManager.Get("DisableInGameHints"), TextManager.Get("DisableInGameHintsTooltip"), unsavedConfig.DisableInGameHints, v => unsavedConfig.DisableInGameHints = v); + Label(left, TextManager.Get("Language"), GUIStyle.SubHeadingFont); + Dropdown(left, v => TextManager.GetTranslatedLanguageName(v), null, languages, unsavedConfig.Language, v => unsavedConfig.Language = v); + Spacer(left); + + Tickbox(left, TextManager.Get("PauseOnFocusLost"), TextManager.Get("PauseOnFocusLostTooltip"), unsavedConfig.PauseOnFocusLost, v => unsavedConfig.PauseOnFocusLost = v); + Spacer(left); + + Tickbox(left, TextManager.Get("DisableInGameHints"), TextManager.Get("DisableInGameHintsTooltip"), unsavedConfig.DisableInGameHints, v => unsavedConfig.DisableInGameHints = v); var resetInGameHintsButton = - new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), layout.RectTransform), + new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), left.RectTransform), TextManager.Get("ResetInGameHints"), style: "GUIButtonSmall") { OnClicked = (button, o) => @@ -715,36 +716,22 @@ namespace Barotrauma return false; } }; - Spacer(layout); + Spacer(left); - Label(layout, TextManager.Get("ShowEnemyHealthBars"), GUIStyle.SubHeadingFont); - DropdownEnum(layout, v => TextManager.Get($"ShowEnemyHealthBars.{v}"), null, unsavedConfig.ShowEnemyHealthBars, v => unsavedConfig.ShowEnemyHealthBars = v); - Spacer(layout); + Label(left, TextManager.Get("ShowEnemyHealthBars"), GUIStyle.SubHeadingFont); + DropdownEnum(left, v => TextManager.Get($"ShowEnemyHealthBars.{v}"), null, unsavedConfig.ShowEnemyHealthBars, v => unsavedConfig.ShowEnemyHealthBars = v); + Spacer(left); + + Label(left, TextManager.Get("HUDScale"), GUIStyle.SubHeadingFont); + Slider(left, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.HUDScale, v => unsavedConfig.Graphics.HUDScale = v); + Label(left, TextManager.Get("InventoryScale"), GUIStyle.SubHeadingFont); + Slider(left, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.InventoryScale, v => unsavedConfig.Graphics.InventoryScale = v); + Label(left, TextManager.Get("TextScale"), GUIStyle.SubHeadingFont); + Slider(left, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.TextScale, v => unsavedConfig.Graphics.TextScale = v); - Label(layout, TextManager.Get("HUDScale"), GUIStyle.SubHeadingFont); - Slider(layout, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.HUDScale, v => unsavedConfig.Graphics.HUDScale = v); - Label(layout, TextManager.Get("InventoryScale"), GUIStyle.SubHeadingFont); - Slider(layout, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.InventoryScale, v => unsavedConfig.Graphics.InventoryScale = v); - Label(layout, TextManager.Get("TextScale"), GUIStyle.SubHeadingFont); - Slider(layout, (0.75f, 1.25f), 51, Percentage, unsavedConfig.Graphics.TextScale, v => unsavedConfig.Graphics.TextScale = v); - Spacer(layout); - var resetSpamListFilter = - new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), layout.RectTransform), - TextManager.Get("clearserverlistfilters"), style: "GUIButtonSmall") - { - OnClicked = static (_, _) => - { - GUI.AskForConfirmation( - header: TextManager.Get("clearserverlistfilters"), - body: TextManager.Get("clearserverlistfiltersconfirmation"), - onConfirm: SpamServerFilters.ClearLocalSpamFilter); - return true; - } - }; - Spacer(layout); #if !OSX - Spacer(layout); - var statisticsTickBox = new GUITickBox(NewItemRectT(layout), TextManager.Get("statisticsconsenttickbox")) + Spacer(right); + var statisticsTickBox = new GUITickBox(NewItemRectT(right), TextManager.Get("statisticsconsenttickbox")) { OnSelected = tickBox => { @@ -767,7 +754,7 @@ namespace Barotrauma void updateGATickBoxToolTip() => statisticsTickBox.ToolTip = TextManager.Get($"GameAnalyticsStatus.{GameAnalyticsManager.UserConsented}"); updateGATickBoxToolTip(); - + var cachedConsent = GameAnalyticsManager.Consent.Unknown; var statisticsTickBoxUpdater = new GUICustomComponent( new RectTransform(Vector2.Zero, statisticsTickBox.RectTransform), @@ -789,6 +776,37 @@ namespace Barotrauma statisticsTickBox.Enabled &= GameAnalyticsManager.UserConsented != GameAnalyticsManager.Consent.Error; }); #endif + //Steam version supports hosting/joining servers using EOS networking + if (SteamManager.IsInitialized) + { + bool shouldCrossplayBeEnabled = unsavedConfig.CrossplayChoice is Eos.EosSteamPrimaryLogin.CrossplayChoice.Enabled; + var crossplayTickBox = Tickbox(right, TextManager.Get("EosAllowCrossplay"), TextManager.Get("EosAllowCrossplayTooltip"), shouldCrossplayBeEnabled, v => + { + unsavedConfig.CrossplayChoice = v + ? Eos.EosSteamPrimaryLogin.CrossplayChoice.Enabled + : Eos.EosSteamPrimaryLogin.CrossplayChoice.Disabled; + }); + if (GameMain.NetworkMember != null) + { + crossplayTickBox.Enabled = false; + crossplayTickBox.ToolTip = TextManager.Get("CantAccessEOSSettingsInMP"); + } + } + + Spacer(right); + var resetSpamListFilter = + new GUIButton(new RectTransform(new Vector2(1.0f, 1.0f), right.RectTransform), + TextManager.Get("clearserverlistfilters"), style: "GUIButtonSmall") + { + OnClicked = static (_, _) => + { + GUI.AskForConfirmation( + header: TextManager.Get("clearserverlistfilters"), + body: TextManager.Get("clearserverlistfiltersconfirmation"), + onConfirm: SpamServerFilters.ClearLocalSpamFilter); + return true; + } + }; } private void CreateModsTab(out WorkshopMenu workshopMenu) @@ -832,9 +850,9 @@ namespace Barotrauma public void ApplyInstalledModChanges() { + EosSteamPrimaryLogin.HandleCrossplayChoiceChange(unsavedConfig.CrossplayChoice); GameSettings.SetCurrentConfig(unsavedConfig); - if (WorkshopMenu is MutableWorkshopMenu mutableWorkshopMenu && - mutableWorkshopMenu.CurrentTab == MutableWorkshopMenu.Tab.InstalledMods) + if (WorkshopMenu is MutableWorkshopMenu { CurrentTab: MutableWorkshopMenu.Tab.InstalledMods } mutableWorkshopMenu) { mutableWorkshopMenu.Apply(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/FriendInfo.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendInfo.cs new file mode 100644 index 000000000..eb17cecd3 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendInfo.cs @@ -0,0 +1,73 @@ +#nullable enable +using System; +using Barotrauma.Networking; + +namespace Barotrauma; + +sealed class FriendInfo : IDisposable +{ + public readonly string Name; + public readonly AccountId Id; + + public readonly FriendStatus CurrentStatus; + public readonly string ServerName; + public readonly Option ConnectCommand; + + public readonly FriendProvider Provider; + public Option Avatar { get; set; } + + public bool IsInServer + => CurrentStatus == FriendStatus.PlayingBarotrauma && ConnectCommand.IsSome(); + + public bool IsOnline + => CurrentStatus != FriendStatus.Offline; + + public LocalizedString StatusText + => CurrentStatus switch + { + FriendStatus.Offline => "", + _ when ConnectCommand.IsSome() + => TextManager.GetWithVariable("FriendPlayingOnServer", "[servername]", ServerName), + _ => TextManager.Get($"Friend{CurrentStatus}") + }; + + public FriendInfo(string name, AccountId id, FriendStatus status, string serverName, Option connectCommand, FriendProvider provider) + { + Name = name; + Id = id; + CurrentStatus = status; + ServerName = serverName; + ConnectCommand = connectCommand; + Provider = provider; + Avatar = Option.None; + } + + public void RetrieveOrInheritAvatar(Option inheritableAvatar, int size) + { + if (Avatar.IsSome()) { return; } + + if (inheritableAvatar.IsSome()) + { + Avatar = inheritableAvatar; + return; + } + + TaskPool.Add( + "RetrieveAvatar", + Provider.RetrieveAvatar(this, size), + t => + { + if (!t.TryGetResult(out Option spr)) { return; } + Avatar = Avatar.Fallback(spr); + }); + } + + public void Dispose() + { + if (Avatar.TryUnwrap(out var avatar)) + { + avatar.Remove(); + } + Avatar = Option.None; + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/CompositeFriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/CompositeFriendProvider.cs new file mode 100644 index 000000000..950881f06 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/CompositeFriendProvider.cs @@ -0,0 +1,46 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Networking; + +namespace Barotrauma; + +sealed class CompositeFriendProvider : FriendProvider +{ + private readonly ImmutableArray providers; + + public CompositeFriendProvider(params FriendProvider[] providers) + { + this.providers = providers.ToImmutableArray(); + } + + public override async Task> RetrieveFriend(AccountId id) + { + return (await Task.WhenAll(providers + .Select(p => p.RetrieveFriend(id)))) + .NotNone().FirstOrNone(); + } + + public override async Task> RetrieveFriends() + { + var friends = await Task.WhenAll(providers.Select(p => p.RetrieveFriends())); + return friends.SelectMany(a => a).ToImmutableArray(); + } + + public override async Task> RetrieveAvatar(FriendInfo friend, int avatarSize) + { + var subTasks = await Task.WhenAll(providers.Select(p => p.RetrieveAvatar(friend, avatarSize))); + return subTasks.FirstOrDefault(t => t.IsSome()); + } + + public override async Task GetSelfUserName() + { + foreach (var provider in providers) + { + string userName = await provider.GetSelfUserName(); + if (userName is { Length: > 0 }) { return userName; } + } + return ""; + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/EpicFriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/EpicFriendProvider.cs new file mode 100644 index 000000000..a1402a974 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/EpicFriendProvider.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Networking; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Vector2 = Microsoft.Xna.Framework.Vector2; + +namespace Barotrauma; + +sealed class EpicFriendProvider : FriendProvider +{ + private FriendInfo EgsFriendToFriendInfo(EosInterface.EgsFriend egsFriend) + { + return new FriendInfo( + name: egsFriend.DisplayName, + id: egsFriend.EpicAccountId, + status: egsFriend.Status, + serverName: egsFriend.ServerName, + connectCommand: ConnectCommand.Parse(egsFriend.ConnectCommand), + provider: this); + } + + public override async Task> RetrieveFriend(AccountId id) + { + if (id is not EpicAccountId friendEaid) { return Option.None; } + var selfEaidOption = Eos.EosAccount.SelfAccountIds.OfType().FirstOrNone(); + if (!selfEaidOption.TryUnwrap(out var selfEaid)) { return Option.None; } + + var friendResult = await EosInterface.Friends.GetFriend(selfEaid, friendEaid); + if (!friendResult.TryUnwrapSuccess(out var f)) { return Option.None; } + + return Option.Some(EgsFriendToFriendInfo(f)); + } + + public override async Task> RetrieveFriends() + { + var epicAccountIdOption = Eos.EosAccount.SelfAccountIds.OfType().FirstOrNone(); + if (!epicAccountIdOption.TryUnwrap(out var epicAccountId)) { return ImmutableArray.Empty; } + + var friendsResult = await EosInterface.Friends.GetFriends(epicAccountId); + if (!friendsResult.TryUnwrapSuccess(out var friends)) { return ImmutableArray.Empty; } + + return friends.Select(EgsFriendToFriendInfo).ToImmutableArray(); + } + + private static readonly ImmutableArray egsProfileColors = new[] + { + // Cyan + new Color(0xfff0a950), + + // Dark green + new Color(0xff2b9850), + + // Yellow-green + new Color(0xff2ba08e), + + // Purple + new Color(0xff951249), + + // Purple-red + new Color(0xff9a0c71), + + // Red + new Color(0xff3e29c6), + + // Orange + new Color(0xff3875ed), + + // Yellow-orange + new Color(0xff1ea5ed) + }.ToImmutableArray(); + + public override Task> RetrieveAvatar(FriendInfo friend, int avatarSize) + { + if (friend.Id is not EpicAccountId epicAccount) { return Task.FromResult>(Option.None); } + + // EGS doesn't have profile pictures yet. + // Instead, each player gets a color based on their account ID. + // This is an educated guess of how Epic picks that color, and is likely incorrect for IDs nearing the boundaries of ranges: + Color color = Color.Black; + if (ulong.TryParse(epicAccount.EosStringRepresentation[..16], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var mostSignificant64Bits) + && ulong.TryParse(epicAccount.EosStringRepresentation[16..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var leastSignificant64Bits)) + { + BigInteger fullId = mostSignificant64Bits; + fullId <<= 64; + fullId |= leastSignificant64Bits; + + BigInteger idMaxValue = ulong.MaxValue; + idMaxValue <<= 64; + idMaxValue |= ulong.MaxValue; + + BigInteger middleRangeSize = idMaxValue / 7; + BigInteger firstRangeSize = middleRangeSize / 2; + if (fullId <= firstRangeSize) + { + color = egsProfileColors[0]; + } + else + { + color = egsProfileColors[(int)((fullId - firstRangeSize) / middleRangeSize) + 1]; + } + } + + char glyphChar = friend.Name.FallbackNullOrEmpty("?")[0]; + var font = GUIStyle.UnscaledSmallFont.GetFontForStr(glyphChar.ToString()); + + Texture2D tex = null; + if (font != null) + { + var (glyphData, glyphTexture) = font.GetGlyphDataAndTextureForChar(glyphChar); + var glyphSize = new Vector2(glyphData.TexCoords.Width, glyphData.TexCoords.Height); + int texSize = (int)Math.Max( + MathUtils.RoundUpToPowerOfTwo((uint)(font.LineHeight * 1.5f)), + MathUtils.RoundUpToPowerOfTwo((uint)(font.LineHeight * 1.5f))); + + if (glyphTexture is not null) + { + var glyphTextureData = new Color[(int)glyphSize.X * (int)glyphSize.Y]; + glyphTexture.GetData( + level: 0, + rect: glyphData.TexCoords, + data: glyphTextureData, + startIndex: 0, + elementCount: glyphTextureData.Length); + + var texData = Enumerable.Range(0, texSize * texSize).Select(_ => color).ToArray(); + var start = (new Vector2(texSize, texSize) / 2 - glyphSize / 2).ToPoint(); + var end = start + glyphSize.ToPoint(); + + for (int x = start.X; x < end.X; x++) + { + for (int y = start.Y; y < end.Y; y++) + { + texData[x + y * texSize] = + Color.Lerp( + color, + Color.White, + glyphTextureData[(x - start.X) + (y - start.Y) * (int)glyphSize.X].A / 255f); + } + } + + tex = new Texture2D(GameMain.GraphicsDeviceManager.GraphicsDevice, texSize, texSize); + tex.SetData(texData); + } + } + + if (tex is null) + { + tex = new Texture2D(GameMain.GraphicsDeviceManager.GraphicsDevice, 2, 2); + tex.SetData(new[] { color, color, color, color }); + } + + + var sprite = new Sprite(tex, null, null); + return Task.FromResult(Option.Some(sprite)); + } + + public override async Task GetSelfUserName() + { + var epicAccountIdOption = Eos.EosAccount.SelfAccountIds.OfType().FirstOrNone(); + if (!epicAccountIdOption.TryUnwrap(out var epicAccountId)) { return ""; } + + var selfInfoResult = await EosInterface.Friends.GetSelfUserInfo(epicAccountId); + if (!selfInfoResult.TryUnwrapSuccess(out var selfInfo)) { return ""; } + + return selfInfo.DisplayName; + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/FriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/FriendProvider.cs new file mode 100644 index 000000000..e3a4f4559 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/FriendProvider.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System.Collections.Immutable; +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma +{ + abstract class FriendProvider + { + public async Task> RetrieveFriendWithAvatar(AccountId id, int size) + { + var friendOption = await RetrieveFriend(id); + if (!friendOption.TryUnwrap(out var friend)) { return Option.None; } + + friend.Avatar = await RetrieveAvatar(friend, size); + return Option.Some(friend); + } + + public abstract Task> RetrieveFriend(AccountId id); + public abstract Task> RetrieveFriends(); + public abstract Task> RetrieveAvatar(FriendInfo friend, int avatarSize); + public abstract Task GetSelfUserName(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/SteamFriendProvider.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/SteamFriendProvider.cs new file mode 100644 index 000000000..fa94b7786 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/FriendProviders/SteamFriendProvider.cs @@ -0,0 +1,69 @@ +#nullable enable +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Networking; +using Barotrauma.Steam; +using Microsoft.Xna.Framework.Graphics; + +namespace Barotrauma +{ + sealed class SteamFriendProvider : FriendProvider + { + private FriendInfo FromSteamFriend(Steamworks.Friend steamFriend) + => new FriendInfo( + name: steamFriend.Name ?? "", + id: new SteamId(steamFriend.Id), + status: steamFriend.State switch + { + Steamworks.FriendState.Offline => FriendStatus.Offline, + Steamworks.FriendState.Invisible => FriendStatus.Offline, + _ when steamFriend.IsPlayingThisGame => FriendStatus.PlayingBarotrauma, + _ when steamFriend.GameInfo is { GameID: > 0 } => FriendStatus.PlayingAnotherGame, + _ => FriendStatus.NotPlaying + }, + serverName: steamFriend.GetRichPresence("servername") ?? "", + connectCommand: steamFriend.GetRichPresence("connect") is { } connectCmd + ? ConnectCommand.Parse(ToolBox.SplitCommand(connectCmd)) + : Option.None, + this); + + public override Task> RetrieveFriend(AccountId id) + => Task.FromResult(id is SteamId steamId + ? Option.Some(FromSteamFriend(new Steamworks.Friend(steamId.Value))) + : Option.None); + + public override Task> RetrieveFriends() + => Task.FromResult(SteamManager.IsInitialized + ? Steamworks.SteamFriends.GetFriends().Select(FromSteamFriend).ToImmutableArray() + : ImmutableArray.Empty); + + public override async Task> RetrieveAvatar(FriendInfo friend, int avatarSize) + { + if (friend.Id is not SteamId steamId) { return Option.None; } + + Func> avatarFunc = avatarSize switch + { + <= 24 => Steamworks.SteamFriends.GetSmallAvatarAsync, + <= 48 => Steamworks.SteamFriends.GetMediumAvatarAsync, + _ => Steamworks.SteamFriends.GetLargeAvatarAsync + }; + + var img = await avatarFunc(steamId.Value).ToOptionTask(); + if (!img.TryUnwrap(out var avatarImage)) { return Option.None; } + + if (friend.Avatar.TryUnwrap(out var prevAvatar)) + { + prevAvatar.Remove(); + } + + var avatarTexture = new Texture2D(GameMain.Instance.GraphicsDevice, (int)avatarImage.Width, (int)avatarImage.Height); + avatarTexture.SetData(avatarImage.Data); + return Option.Some(new Sprite(texture: avatarTexture, sourceRectangle: null, newOffset: null)); + } + + public override Task GetSelfUserName() + => Task.FromResult(SteamManager.GetUsername()); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/SocialExtensions.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/SocialExtensions.cs new file mode 100644 index 000000000..08973aca1 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/SocialExtensions.cs @@ -0,0 +1,34 @@ +using Barotrauma.Networking; +using Barotrauma.Steam; + +namespace Barotrauma; + +static class SocialExtensions +{ + public static LocalizedString ViewProfileLabel(this AccountId accountId) + => accountId switch + { + SteamId => TextManager.Get("ViewSteamProfile"), + EpicAccountId => TextManager.Get("ViewEpicProfile"), + _ => "View profile of unknown origin" + }; + + public static void OpenProfile(this AccountId accountId) + { + string url = accountId switch + { + SteamId steamId => $"https://steamcommunity.com/profiles/{steamId.Value}", + EpicAccountId epicAccountId => $"https://store.epicgames.com/u/{epicAccountId.EosStringRepresentation}", + _ => "" + }; + + if (SteamManager.IsInitialized) + { + SteamManager.OverlayCustomUrl(url); + } + else + { + GameMain.ShowOpenUriPrompt(url); + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Social/SocialOverlay.cs b/Barotrauma/BarotraumaClient/ClientSource/Social/SocialOverlay.cs new file mode 100644 index 000000000..76fc18ba2 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/Social/SocialOverlay.cs @@ -0,0 +1,1063 @@ +#nullable enable +using Barotrauma.Eos; +using Barotrauma.Extensions; +using Barotrauma.Networking; +using Barotrauma.Steam; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Barotrauma; + +sealed class SocialOverlay : IDisposable +{ + public static readonly LocalizedString ShortcutBindText = TextManager.Get("SocialOverlayShortcutBind"); + + public static SocialOverlay? Instance { get; private set; } + public static void Init() + { + Instance ??= new SocialOverlay(); + } + + private sealed class NotificationHandler + { + public record Notification( + DateTime ReceiveTime, + GUIComponent GuiElement); + private readonly List notifications = new(); + + private static readonly TimeSpan notificationDuration = TimeSpan.FromSeconds(8); + private static readonly TimeSpan notificationEasingTimeSpan = TimeSpan.FromSeconds(0.5); + public readonly GUIFrame NotificationContainer = + new GUIFrame(new RectTransform((0.4f, 0.15f), GUI.Canvas, Anchor.BottomRight, scaleBasis: ScaleBasis.BothHeight), style: null) + { + CanBeFocused = false + }; + + public void Update() + { + var now = DateTime.Now; + float cumulativeNotificationOffset = 0; + + for (int i = notifications.Count - 1; i >= 0; i--) + { + var notification = notifications[i]; + + var expiryTime = notification.ReceiveTime + notificationDuration; + if (now > expiryTime + || notification.GuiElement.Parent is null) + { + RemoveNotification(notification); + continue; + } + + TimeSpan diffToStart = now - notification.ReceiveTime; + TimeSpan diffToEnd = expiryTime - now; + + float offsetToAdd = 1f; + offsetToAdd = Math.Min( + offsetToAdd, + (float)diffToStart.TotalSeconds / (float)notificationEasingTimeSpan.TotalSeconds); + offsetToAdd = Math.Min( + offsetToAdd, + (float)diffToEnd.TotalSeconds / (float)notificationEasingTimeSpan.TotalSeconds); + + offsetToAdd = Math.Max(offsetToAdd, 0f); + + cumulativeNotificationOffset += offsetToAdd; + + notification.GuiElement.RectTransform.RelativeOffset = (0, cumulativeNotificationOffset - 1f); + } + } + + public void AddToGuiUpdateList() + { + NotificationContainer.AddToGUIUpdateList(); + } + + public void AddNotification(Notification notification) + { + notifications.Add(notification); + } + + public void RemoveNotification(Notification notification) + { + notifications.Remove(notification); + NotificationContainer.RemoveChild(notification.GuiElement); + } + } + + private sealed class InviteHandler : IDisposable + { + private readonly record struct Invite( + FriendInfo Sender, + DateTime ReceiveTime, + Option NotificationOption); + + private readonly SocialOverlay socialOverlay; + private readonly FriendProvider friendProvider; + private readonly NotificationHandler notificationHandler; + + private readonly List invites = new List(); + private static readonly TimeSpan inviteDuration = TimeSpan.FromMinutes(5); + private readonly Identifier inviteReceivedEventIdentifier; + + public InviteHandler( + SocialOverlay inSocialOverlay, + FriendProvider inFriendProvider, + NotificationHandler inNotificationHandler) + { + socialOverlay = inSocialOverlay; + friendProvider = inFriendProvider; + notificationHandler = inNotificationHandler; + + inviteReceivedEventIdentifier = GetHashCode().ToIdentifier(); + EosInterface.Presence.OnInviteReceived.Register( + identifier: inviteReceivedEventIdentifier, + OnEosInviteReceived); + Steamworks.SteamFriends.OnChatMessage += OnSteamChatMsgReceived; + } + + private void OnSteamChatMsgReceived(Steamworks.Friend steamFriend, string msgType, string msgContent) + { + if (!string.Equals(msgType, "InviteGame")) { return; } + + var friendId = new SteamId(steamFriend.Id); + TaskPool.Add( + $"ReceivedInviteFrom{friendId}", + friendProvider.RetrieveFriend(friendId), + t => + { + if (!t.TryGetResult(out Option friendInfoOption)) { return; } + if (!friendInfoOption.TryUnwrap(out var friendInfo)) { return; } + RegisterInvite(friendInfo, showNotification: false); + }); + } + + private void OnEosInviteReceived(EosInterface.Presence.ReceiveInviteInfo info) + { + TaskPool.Add( + $"ReceivedInviteFrom{info.SenderId}", + friendProvider.RetrieveFriendWithAvatar(info.SenderId, notificationHandler.NotificationContainer.Rect.Height), + t => + { + if (!t.TryGetResult(out Option friendInfoOption)) { return; } + if (!friendInfoOption.TryUnwrap(out var friendInfo)) { return; } + RegisterInvite(friendInfo, showNotification: true); + }); + } + + public bool HasInviteFrom(AccountId sender) + => invites.Any(invite => invite.Sender.Id == sender); + + public void ClearInvitesFrom(AccountId sender) + { + foreach (var invite in invites) + { + if (invite.Sender.Id == sender && invite.NotificationOption.TryUnwrap(out var notification)) + { + notificationHandler.RemoveNotification(notification); + } + } + invites.RemoveAll(invite => invite.Sender.Id == sender); + + if (sender is not EpicAccountId friendEpicId) { return; } + + var selfEpicIds = EosInterface.IdQueries.GetLoggedInEpicIds(); + if (selfEpicIds.Length == 0) { return; } + + var selfEpicId = selfEpicIds[0]; + EosInterface.Presence.DeclineInvite(selfEpicId, friendEpicId); + } + + public void Update() + { + var now = DateTime.Now; + + for (int i = invites.Count - 1; i >= 0; i--) + { + var invite = invites[i]; + + var expiryTime = invite.ReceiveTime + inviteDuration; + if (now > expiryTime) + { + if (invite.NotificationOption.TryUnwrap(out var notification)) + { + notificationHandler.RemoveNotification(notification); + } + invites.RemoveAt(i); + } + } + } + + private void RegisterInvite(FriendInfo senderInfo, bool showNotification) + { + var now = DateTime.Now; + + var invite = new Invite( + Sender: senderInfo, + ReceiveTime: now, + NotificationOption: Option.None); + + if (showNotification) + { + var baseButton = new GUIButton( + new RectTransform(Vector2.One, notificationHandler.NotificationContainer.RectTransform, Anchor.BottomRight) + { + RelativeOffset = (0, -1) + }, style: "SocialOverlayPopup"); + baseButton.Frame.OutlineThickness = 1f; + + var topLayout = new GUILayoutGroup(new RectTransform(Vector2.One, baseButton.RectTransform), isHorizontal: true) + { + Stretch = true, + RelativeSpacing = 0.05f + }; + + var avatarContainer = new GUIFrame(new RectTransform(Vector2.One, topLayout.RectTransform, scaleBasis: ScaleBasis.BothHeight), style: null); + + var avatarComponent = new GUICustomComponent( + new RectTransform( + Vector2.One * 0.8f, + avatarContainer.RectTransform, + Anchor.Center, + scaleBasis: ScaleBasis.BothHeight), + onDraw: (sb, component) => + { + if (!senderInfo.Avatar.TryUnwrap(out var avatar)) { return; } + + var rect = component.Rect; + sb.Draw(avatar.Texture, rect, avatar.Texture.Bounds, Color.White); + }); + + var textLayout = new GUILayoutGroup(new RectTransform(Vector2.One, topLayout.RectTransform)) + { + Stretch = true + }; + + void addPadding() + => new GUIFrame(new RectTransform((1.0f, 0.2f), textLayout.RectTransform), style: null); + + void addText(LocalizedString text, GUIFont font) + => new GUITextBlock(new RectTransform((1.0f, 0.2f), textLayout.RectTransform), text, font: font); + + addPadding(); + addText(senderInfo.Name, GUIStyle.SubHeadingFont); + addText(TextManager.Get("InvitedYou"), GUIStyle.Font); + addPadding(); + addText(TextManager.GetWithVariable("ClickHereOrPressSocialOverlayShortcut", "[shortcut]", ShortcutBindText), GUIStyle.SmallFont); + addPadding(); + + var notification = new NotificationHandler.Notification( + ReceiveTime: now, + GuiElement: baseButton); + baseButton.OnClicked = (_, _) => + { + socialOverlay.IsOpen = true; + notificationHandler.RemoveNotification(notification); + return false; + }; + baseButton.OnSecondaryClicked = (_, _) => + { + notificationHandler.RemoveNotification(notification); + return false; + }; + + notificationHandler.AddNotification(notification); + + invite = invite with { NotificationOption = Option.Some(notification) }; + } + + invites.Add(invite); + } + + public void Dispose() + { + EosInterface.Presence.OnInviteReceived.Deregister(inviteReceivedEventIdentifier); + Steamworks.SteamFriends.OnChatMessage -= OnSteamChatMsgReceived; + } + } + + private readonly NotificationHandler notificationHandler; + private readonly InviteHandler inviteHandler; + private readonly GUIFrame background; + private readonly GUIButton linkHint; + private readonly GUILayoutGroup contentLayout; + + private readonly GUIFrame selectedFriendInfoFrame; + + private const float WidthToHeightRatio = 7f; + + private readonly TimeSpan refreshInterval = TimeSpan.FromSeconds(30); + private DateTime lastRefreshTime; + + public bool IsOpen; + + private static RectTransform CreateRowRectT(GUIComponent parent, float heightScale = 1f) + => new RectTransform((1.0f, heightScale / WidthToHeightRatio), parent.RectTransform, scaleBasis: ScaleBasis.BothWidth); + + private static GUILayoutGroup CreateRowLayout(GUIComponent parent, float heightScale = 1f) + { + var rowLayout = new GUILayoutGroup(CreateRowRectT(parent, heightScale), isHorizontal: true) + { + Stretch = true + }; + + new GUICustomComponent(new RectTransform(Vector2.Zero, rowLayout.RectTransform), + onUpdate: (f, component) => + { + rowLayout.RectTransform.NonScaledSize = calculateSize(); + }); + + return rowLayout; + + Point calculateSize() => new Point(parent.Rect.Width, (int)((parent.Rect.Width * heightScale) / WidthToHeightRatio)); + } + + private readonly struct PlayerRow + { + public readonly GUIFrame AvatarContainer; + public readonly GUIFrame InfoContainer; + public readonly FriendInfo FriendInfo; + + internal PlayerRow(FriendInfo friendInfo, GUILayoutGroup containerLayout, bool invitedYou, IEnumerable? metadataText = null) + { + FriendInfo = friendInfo; + AvatarContainer = new GUIFrame(new RectTransform(Vector2.One, containerLayout.RectTransform, scaleBasis: ScaleBasis.BothHeight), style: null); + InfoContainer = new GUIFrame(new RectTransform(Vector2.One, containerLayout.RectTransform, scaleBasis: ScaleBasis.Normal), style: null); + + friendInfo.RetrieveOrInheritAvatar(Option.None, AvatarContainer.Rect.Height); + + var avatarBackground = new GUIFrame(new RectTransform(Vector2.One * 0.9f, AvatarContainer.RectTransform, Anchor.Center), + style: invitedYou + ? "FriendInvitedYou" + : $"Friend{friendInfo.CurrentStatus}"); + + var textLayout = new GUILayoutGroup(new RectTransform(Vector2.One, InfoContainer.RectTransform)) { Stretch = true }; + var textBlocks = new List(); + + addTextLayoutPadding(); + addTextBlock(friendInfo.Name, font: GUIStyle.SubHeadingFont); + metadataText ??= new[] { friendInfo.StatusText }; + foreach (var line in metadataText) + { + addTextBlock(line, font: GUIStyle.Font); + } + addTextLayoutPadding(); + + new GUICustomComponent(new RectTransform(Vector2.One, avatarBackground.RectTransform), + onUpdate: updateTextAlignments, + onDraw: drawAvatar); + + if (invitedYou) + { + var inviteIcon = new GUIImage(new RectTransform(new Vector2(0.5f), avatarBackground.RectTransform, Anchor.TopRight, Pivot.Center) + { RelativeOffset = Vector2.One * 0.15f }, style: "InviteNotification") + { + ToolTip = TextManager.Get("InviteNotification") + }; + inviteIcon.OnAddedToGUIUpdateList += (GUIComponent component) => + { + if (component.FlashTimer <= 0.0f) + { + component.Flash(GUIStyle.Green, useCircularFlash: true); + component.Pulsate(Vector2.One, Vector2.One * 1.5f, 0.5f); + } + }; + } + + void addTextLayoutPadding() + => new GUIFrame(new RectTransform(Vector2.One, textLayout.RectTransform), style: null); + + void addTextBlock(LocalizedString text, GUIFont font) + => textBlocks.Add(new GUITextBlock(new RectTransform(Vector2.One, textLayout.RectTransform), text, + textColor: Color.White, font: font, textAlignment: Alignment.CenterLeft) + { + ForceUpperCase = ForceUpperCase.No, + TextColor = avatarBackground.Color, + HoverTextColor = avatarBackground.HoverColor, + SelectedTextColor = avatarBackground.SelectedColor, + PressedColor = avatarBackground.PressedColor, + }); + + void updateTextAlignments(float deltaTime, GUICustomComponent component) + { + foreach (var textBlock in textBlocks) + { + int height = (int)textBlock.Font.LineHeight + GUI.IntScale(2); + textBlock.RectTransform.NonScaledSize = + (textBlock.RectTransform.NonScaledSize.X, height); + } + textLayout.NeedsToRecalculate = true; + } + + void drawAvatar(SpriteBatch sb, GUICustomComponent component) + { + if (!friendInfo.Avatar.TryUnwrap(out var avatar)) { return; } + Rectangle rect = component.Rect; + rect.Inflate(-GUI.IntScale(4f), -GUI.IntScale(4f)); + sb.Draw(avatar.Texture, rect, Color.White); + } + } + } + + private readonly FriendProvider friendProvider; + + private readonly GUILayoutGroup selfPlayerRowLayout; + + private readonly GUIButton? eosConfigButton; + private readonly GUILayoutGroup? eosStatusTextContainer; + private EosInterface.Core.Status eosLastKnownStatus; + + private readonly GUIListBox friendPlayerListBox; + private readonly List friendPlayerRows = new List(); + + private void RecreateSelfPlayerRow() + { + if (SteamManager.GetSteamId().TryUnwrap(out var steamId)) + { + selfPlayerRowLayout.ClearChildren(); + _ = new PlayerRow( + new FriendInfo( + name: SteamManager.GetUsername(), + id: steamId, + status: FriendStatus.PlayingBarotrauma, + serverName: "", + connectCommand: Option.None, + provider: friendProvider), + selfPlayerRowLayout, + invitedYou: false); + } + else if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + static async Task> GetEpicAccountInfo() + { + if (!EosAccount.SelfAccountIds.OfType().FirstOrNone().TryUnwrap(out var epicAccountId)) + { + return Option.None; + } + + var selfUserInfoResult = await EosInterface.Friends.GetSelfUserInfo(epicAccountId); + + if (!selfUserInfoResult.TryUnwrapSuccess(out var selfUserInfo)) + { + return Option.None; + } + + return Option.Some(selfUserInfo); + } + + TaskPool.Add( + "GetEpicAccountIdForSelfPlayerRow", + GetEpicAccountInfo(), + t => + { + if (!t.TryGetResult(out Option userInfoOption) + || !userInfoOption.TryUnwrap(out var userInfo)) + { + return; + } + + selfPlayerRowLayout.ClearChildren(); + _ = new PlayerRow( + new FriendInfo( + name: userInfo.DisplayName, + id: userInfo.EpicAccountId, + status: FriendStatus.PlayingBarotrauma, + serverName: "", + connectCommand: Option.None, + provider: friendProvider), + selfPlayerRowLayout, + invitedYou: false); + }); + } + } + + private SocialOverlay() + { + background = + new GUIFrame(new RectTransform(GUI.Canvas.RelativeSize, GUI.Canvas, Anchor.Center), style: "SocialOverlayBackground"); + var rightSideLayout = + new GUILayoutGroup( + new RectTransform((0.9f, 1.0f), background.RectTransform, Anchor.CenterRight, + scaleBasis: ScaleBasis.BothHeight), isHorizontal: true, childAnchor: Anchor.BottomLeft); + + linkHint = new GUIButton(new RectTransform((0.5f, 0.9f / WidthToHeightRatio), rightSideLayout.RectTransform, Anchor.BottomRight, scaleBasis: ScaleBasis.BothWidth), style: "FriendsButton") + { + OnClicked = (btn, _) => + { + eosConfigButton?.Flash(GUIStyle.Green); + EosSteamPrimaryLogin.IsNewEosPlayer = false; + btn.Visible = false; + return false; + }, + Visible = false + }; + _ = new GUITextBlock(new RectTransform(Vector2.One * 0.95f, linkHint.RectTransform, Anchor.Center), + text: TextManager.Get("EosSettings.RecommendLinkingToEpicAccount"), + wrap: true, + style: "FriendsButton"); + + var content = new GUIFrame( + new RectTransform((0.5f, 1.0f), rightSideLayout.RectTransform), + style: "SocialOverlayFriendsList"); + + _ = new GUIButton( + new RectTransform(Vector2.One * 0.08f, content.RectTransform, Anchor.TopLeft, Pivot.TopRight, + scaleBasis: ScaleBasis.BothWidth) + { + RelativeOffset = (-0.03f, 0.015f) + }, + style: "SocialOverlayCloseButton") + { + OnClicked = (_, _) => + { + IsOpen = false; + return false; + } + }; + + friendProvider = new CompositeFriendProvider(new SteamFriendProvider(), new EpicFriendProvider()); + + notificationHandler = new NotificationHandler(); + inviteHandler = new InviteHandler( + inSocialOverlay: this, + inFriendProvider: friendProvider, + inNotificationHandler: notificationHandler); + + selectedFriendInfoFrame = new GUIFrame(new RectTransform((0.25f, 0.28f), background.RectTransform, + Anchor.TopRight, scaleBasis: ScaleBasis.BothHeight), style: "SocialOverlayPopup") + { + OutlineThickness = 1f, + Visible = false + }; + + contentLayout = new GUILayoutGroup(new RectTransform(Vector2.One, content.RectTransform)) { Stretch = true }; + + selfPlayerRowLayout = CreateRowLayout(contentLayout); + RecreateSelfPlayerRow(); + + friendPlayerListBox = + new GUIListBox(new RectTransform(Vector2.One, contentLayout.RectTransform), style: null) + { + OnSelected = (component, userData) => + { + if (userData is not FriendInfo friendInfo) { return false; } + selectedFriendInfoFrame.Visible = true; + selectedFriendInfoFrame.RectTransform.AbsoluteOffset = ( + X: background.Rect.Right - component.Rect.X, + Y: Math.Clamp( + value: component.Rect.Center.Y - selectedFriendInfoFrame.Rect.Height / 2, + min: 0, + max: background.Rect.Bottom - selectedFriendInfoFrame.Rect.Height)); + PopulateSelectedFriendInfoFrame(friendInfo); + return true; + } + }; + friendPlayerListBox.ScrollBar.OnMoved += (_, _) => { friendPlayerListBox.Deselect(); return true; }; + + if (SteamManager.IsInitialized) + { + var eosConfigRowLayout = CreateRowLayout(contentLayout, heightScale: 1.5f); + eosConfigRowLayout.ChildAnchor = Anchor.CenterLeft; + + eosConfigButton = new GUIButton( + new RectTransform(Vector2.One * 0.8f, eosConfigRowLayout.RectTransform, scaleBasis: ScaleBasis.BothHeight), + style: null) + { + Enabled = GameMain.NetworkMember == null, + OnClicked = (_, _) => { ShowEosSettingsMenu(); return true; } + }; + new GUIFrame(new RectTransform(Vector2.One * 0.5f, eosConfigButton.RectTransform, Anchor.Center), style: "GUIButtonSettings") + { + CanBeFocused = false + }; + + eosStatusTextContainer = new GUILayoutGroup(new RectTransform(Vector2.One, eosConfigRowLayout.RectTransform)); + RefreshEosStatusText(); + } + + RefreshFriendList(); + } + + public void DisplayBindHintToPlayer() + { + if (IsOpen) { return; } + + var baseButton = new GUIButton( + new RectTransform(Vector2.One, notificationHandler.NotificationContainer.RectTransform, Anchor.BottomRight) + { + RelativeOffset = (0, -1) + }, style: "SocialOverlayPopup"); + baseButton.Frame.OutlineThickness = 1f; + + var notification = new NotificationHandler.Notification( + ReceiveTime: DateTime.Now, + GuiElement: baseButton); + baseButton.OnClicked = (_, _) => + { + IsOpen = true; + notificationHandler.RemoveNotification(notification); + return false; + }; + baseButton.OnSecondaryClicked = (_, _) => + { + notificationHandler.RemoveNotification(notification); + return false; + }; + + _ = new GUITextBlock( + new RectTransform(Vector2.One * 0.98f, baseButton.RectTransform, Anchor.Center), + text: TextManager.GetWithVariable("SocialOverlayShortcutHint", "[shortcut]", ShortcutBindText), + textAlignment: Alignment.Center, + wrap: true) + { + CanBeFocused = false + }; + + notificationHandler.AddNotification(notification); + } + + private void ShowEosSettingsMenu() + { + bool hasEpicAccount = EosAccount.SelfAccountIds.OfType().Any(); + string manageAccountsText = hasEpicAccount + ? "EosSettings.ManageConnectedAccounts" + : "EosSettings.LinkToEpicAccount"; + + bool eosEnabled = EosInterface.Core.IsInitialized; + string enableButtonText = eosEnabled ? "EosSettings.DisableEos" : "EosSettings.EnableEos"; + + var msgBox = new GUIMessageBox(TextManager.Get("EosSettings"), string.Empty, + new LocalizedString[] + { + TextManager.Get(manageAccountsText), + TextManager.Get(enableButtonText), + TextManager.Get("EosSettings.RequestDeletion") + }, minSize: new Point(GUI.IntScale(550), 0)) + { + DrawOnTop = true + }; + msgBox.Buttons[0].Enabled = eosEnabled; + msgBox.Buttons[0].ToolTip = TextManager.Get($"{manageAccountsText}.Tooltip"); + msgBox.Buttons[1].ToolTip = TextManager.Get($"{enableButtonText}.Tooltip"); + msgBox.Buttons[2].ToolTip = TextManager.Get("EosSettings.RequestDeletion.Tooltip"); + + var closeButton = new GUIButton(new RectTransform(new Point(GUI.IntScale(35)), msgBox.InnerFrame.RectTransform, Anchor.TopRight) { AbsoluteOffset = new Point(GUI.IntScale(8)) }, + style: "SocialOverlayCloseButton") + { + OnClicked = closeMsgBox(msgBox) + }; + + msgBox.Buttons[0].OnClicked += (_, _) => + { + if (!hasEpicAccount) + { + //attempt to create an epic account and link it with the Steam account + var loadingBox = GUIMessageBox.CreateLoadingBox( + text: TextManager.Get("EosLinkSteamToEpicLoadingText"), + new[] { (TextManager.Get("Cancel"), new Action(msgBox => msgBox.Close())) }, + relativeSize: (0.35f, 0.25f)); + loadingBox.DrawOnTop = true; + TaskPool.Add( + $"LoginToEpicAccountAsSecondary", + EosEpicSecondaryLogin.LoginToLinkedEpicAccount(), + t => + { + if (t.TryGetResult(out Result? result)) + { + LocalizedString taskResultMsg; + if (result.IsSuccess) + { + taskResultMsg = TextManager.Get("EosLinkSuccess"); + } + else if (result.TryUnwrapFailure(out var failure)) + { + taskResultMsg = TextManager.GetWithVariable("EosLinkError", "[error]", failure.ToString()); + } + else + { + taskResultMsg = TextManager.GetWithVariable("EosLinkError", "[error]", result.ToString()); + } + + var msgBox = new GUIMessageBox( + TextManager.Get("EosSettings.LinkToEpicAccount"), + taskResultMsg, + new[] + { + TextManager.Get("OK"), + }) + { + DrawOnTop = true + }; + msgBox.Buttons[0].OnClicked = closeMsgBox(msgBox); + } + loadingBox.Close(); + }); + msgBox.Close(); + } + else + { + //if the user has an epic account, we can just go and link it in the browser + const string url = "https://www.epicgames.com/account/connections"; + var prompt = GameMain.ShowOpenUriPrompt(url); + prompt.DrawOnTop = true; + msgBox.Close(); + } + return true; + }; + msgBox.Buttons[1].OnClicked += (btn, obj) => + { + var crossplayChoice = eosEnabled + ? EosSteamPrimaryLogin.CrossplayChoice.Disabled + : EosSteamPrimaryLogin.CrossplayChoice.Enabled; + EosSteamPrimaryLogin.HandleCrossplayChoiceChange(crossplayChoice); + GameSettings.SetCurrentConfig(GameSettings.CurrentConfig with { CrossplayChoice = crossplayChoice }); + GameSettings.SaveCurrentConfig(); + closeMsgBox(msgBox)(btn, obj); + return true; + }; + msgBox.Buttons[2].OnClicked += (btn, obj) => + { + const string emailAddress = "contact@barotraumagame.com"; + const string subject = "Requesting account information deletion"; + string bodyText = "I would like to delete all of my account information stored by Epic Games."; + + bool epicAccountIdAvailable = EosAccount.SelfAccountIds.OfType().Any(); + bool steamIdAvailable = SteamManager.GetSteamId().TryUnwrap(out SteamId? steamId); + if (!steamIdAvailable && !epicAccountIdAvailable) + { + new GUIMessageBox(TextManager.Get("Error"), TextManager.GetWithVariable( + "EosSettings.RequestDeletion.NoAccountId", + "[emailAddress]", + emailAddress)); + return false; + } + + if (epicAccountIdAvailable) + { + bodyText += $"\n\nMy Epic Account ID(s): {string.Join(", ", EosAccount.SelfAccountIds.OfType().Select(id => id.StringRepresentation))}"; + } + if (steamIdAvailable) + { + bodyText += $"\n\nMy Steam ID: {steamId!.StringRepresentation}"; + } + + string uri = + $"mailto:{emailAddress}?" + + $"subject={Uri.EscapeDataString(subject)}" + + $"&body={Uri.EscapeDataString(bodyText)}"; + var prompt = GameMain.ShowOpenUriPrompt(uri, + TextManager.GetWithVariables("OpenLinkInEmailClient", + ("[recipient]", emailAddress), + ("[message]", bodyText))); + + if (prompt != null) + { + prompt.DrawOnTop = true; + } + + closeMsgBox(msgBox)(btn, obj); + return true; + }; + return; + + GUIButton.OnClickedHandler closeMsgBox(GUIMessageBox msgBox) + { + return (button, obj) => + { + RefreshEosStatusText(); + return msgBox.Close(button, obj); + }; + } + } + + private void PopulateSelectedFriendInfoFrame(FriendInfo friendInfo) + { + selectedFriendInfoFrame.ClearChildren(); + var layout = + new GUILayoutGroup(new RectTransform(Vector2.One * 0.9f, selectedFriendInfoFrame.RectTransform, + Anchor.Center)) + { + Stretch = true, + RelativeSpacing = 0.02f + }; + + addPadding(); + new GUITextBlock( + new RectTransform((1.0f, 0.08f), layout.RectTransform), + text: friendInfo.Name, + font: GUIStyle.SubHeadingFont, + textAlignment: Alignment.Center) + { + ForceUpperCase = ForceUpperCase.No + }; + new GUITextBlock( + new RectTransform((1.0f, 0.08f), layout.RectTransform), + text: friendInfo.StatusText, + font: GUIStyle.Font, + textAlignment: Alignment.TopCenter) + { + ForceUpperCase = ForceUpperCase.No + }; + addPadding(); + var viewProfileButton = addButton(friendInfo.Id.ViewProfileLabel()); + viewProfileButton.OnClicked = (_, _) => + { + friendInfo.Id.OpenProfile(); + return false; + }; + if (friendInfo.IsInServer && + /* don't allow joining other servers when hosting */ + GameMain.Client is not { IsServerOwner: true } && + /* can't join if already joined */ + friendInfo.ConnectCommand.TryUnwrap(out var command) && !command.IsClientConnectedToEndpoint()) + { + var joinButton = addButton(TextManager.Get("ServerListJoin")); + joinButton.OnClicked = (_, _) => + { + GameMain.Instance.ConnectCommand = friendInfo.ConnectCommand; + selectedFriendInfoFrame.Visible = false; + IsOpen = false; + return false; + }; + } + if (inviteHandler.HasInviteFrom(friendInfo.Id)) + { + var declineButton = addButton(TextManager.Get("DeclineInvite")); + declineButton.OnClicked = (_, _) => + { + inviteHandler.ClearInvitesFrom(friendInfo.Id); + selectedFriendInfoFrame.Visible = false; + return false; + }; + } + if (GameMain.Client is not null) + { + var inviteButton = addButton(TextManager.Get("InviteFriend")); + inviteButton.OnClicked = (_, _) => + { + selectedFriendInfoFrame.Visible = false; + var connectCommandOption = (GameMain.Client?.ClientPeer.ServerEndpoint) switch + { + LidgrenEndpoint lidgrenEndpoint => Option.Some(new ConnectCommand(GameMain.Client.Name, lidgrenEndpoint)), + P2PEndpoint or PipeEndpoint => Option.Some(new ConnectCommand(GameMain.Client.Name, GameMain.Client.ClientPeer.AllServerEndpoints.OfType().ToImmutableArray())), + _ => Option.None + }; + if (!connectCommandOption.TryUnwrap(out var connectCommand)) + { + DebugConsole.AddWarning($"Could not create an invite for the endpoint {GameMain.Client?.ClientPeer.ServerEndpoint}."); + return false; + } + + if (friendInfo.Id is SteamId friendSteamId && SteamManager.IsInitialized) + { + var steamFriend = new Steamworks.Friend(friendSteamId.Value); + steamFriend.InviteToGame(connectCommand.ToString()); + } + else if (friendInfo.Id is EpicAccountId friendEpicId && EosInterface.Core.IsInitialized) + { + async Task sendEpicInvite() + { + var selfEpicIds = EosInterface.IdQueries.GetLoggedInEpicIds(); + if (selfEpicIds.Length == 0) { return; } + + var selfEpicId = selfEpicIds[0]; + await EosInterface.Presence.SendInvite(selfEpicId, friendEpicId); + } + + TaskPool.Add( + $"Invite{friendEpicId}", + sendEpicInvite(), + _ => { }); + } + return false; + }; + } + addPadding(); + + void addPadding() + => new GUIFrame(new RectTransform((1.0f, 0.05f), layout.RectTransform), style: null); + + GUIButton addButton(LocalizedString label) + => new GUIButton(new RectTransform((1.0f, 0.08f), layout.RectTransform), label, style: "SocialOverlayButton"); + } + + private void RefreshEosStatusText() + { + if (eosStatusTextContainer is null) { return; } + + eosStatusTextContainer.ClearChildren(); + bool linkedToEpicAccount = EosAccount.SelfAccountIds.OfType().Any(); + _ = new GUITextBlock(new RectTransform(Vector2.One, eosStatusTextContainer.RectTransform), + textAlignment: Alignment.CenterLeft, + wrap: true, + text: TextManager.Get($"EosStatus.{EosInterface.Core.CurrentStatus}") + + "\n" + + TextManager.Get(linkedToEpicAccount + ? "EosSettings.LinkedToAccount" + : "EosSettings.NotLinkedToAccount")); + + linkHint.Visible = !linkedToEpicAccount && EosSteamPrimaryLogin.IsNewEosPlayer; + } + + public void RefreshFriendList() + { + EosAccount.RefreshSelfAccountIds(onRefreshComplete: () => + { + RefreshEosStatusText(); + lastRefreshTime = DateTime.Now; + + if (EosInterface.Core.CurrentStatus != EosInterface.Core.Status.Online + && !SteamManager.IsInitialized) + { + friendPlayerListBox.ClearChildren(); + var offlineLabel = insertLabel(TextManager.Get("SocialOverlayOffline"), heightScale: 4.0f); + offlineLabel.Wrap = true; + + return; + } + + TaskPool.Add( + "RefreshFriendList", + friendProvider.RetrieveFriends(), + t => + { + if (!t.TryGetResult(out ImmutableArray friends)) + { + return; + } + + friendPlayerListBox.ClearChildren(); + friendPlayerRows.ForEach(f => f.FriendInfo.Dispose()); + friendPlayerRows.Clear(); + + var friendsOrdered = friends + .OrderByDescending(f => f.CurrentStatus) + .ThenByDescending(f => inviteHandler.HasInviteFrom(f.Id)) + .ThenBy(f => f.Name) + .ToImmutableArray(); + bool prevWasOnline = true; + if (friendsOrdered.Length > 0 && friendsOrdered[0].IsOnline) + { + insertLabel(TextManager.Get("Label.OnlineLabel")); + } + + for (int friendIndex = 0; friendIndex < friendsOrdered.Length; friendIndex++) + { + var friend = friendsOrdered[friendIndex]; + if (prevWasOnline && !friend.IsOnline) + { + if (friendIndex > 0) + { + insertLabel(""); + } + + insertLabel(TextManager.Get("Label.OfflineLabel")); + } + + var friendFrame = new GUIFrame(CreateRowRectT(friendPlayerListBox.Content), + style: "ListBoxElement") + { + UserData = friend + }; + GUILayoutGroup newRowLayout = CreateRowLayout(friendFrame); + newRowLayout.RectTransform.RelativeSize = Vector2.One; + newRowLayout.RectTransform.ScaleBasis = ScaleBasis.Normal; + var newRow = new PlayerRow(friend, newRowLayout, + invitedYou: inviteHandler.HasInviteFrom(friend.Id)); + friendPlayerRows.Add(newRow); + + prevWasOnline = friend.IsOnline; + } + + contentLayout.Recalculate(); + friendPlayerListBox.UpdateScrollBarSize(); + }); + }); + + GUITextBlock insertLabel(LocalizedString text, float heightScale = 0.5f) + { + var labelContainer = new GUIFrame(CreateRowRectT(friendPlayerListBox.Content), style: null) + { + CanBeFocused = false + }; + Vector2 oldRelativeSize = labelContainer.RectTransform.RelativeSize; + labelContainer.RectTransform.RelativeSize + = (oldRelativeSize.X, oldRelativeSize.Y * heightScale); + return new GUITextBlock(new RectTransform(Vector2.One, labelContainer.RectTransform), + text: text, + font: GUIStyle.SubHeadingFont); + } + } + + public void AddToGuiUpdateList() + { + if (IsOpen) + { + background.AddToGUIUpdateList(); + } + notificationHandler.AddToGuiUpdateList(); + } + + public void Update() + { + inviteHandler.Update(); + notificationHandler.Update(); + + if (!IsOpen) { return; } + + if (selectedFriendInfoFrame.Visible) + { + if (PlayerInput.PrimaryMouseButtonClicked() + && selectedFriendInfoFrame.Visible + && !GUI.IsMouseOn(friendPlayerListBox) + && !GUI.IsMouseOn(selectedFriendInfoFrame)) + { + friendPlayerListBox.Deselect(); + } + + if (GUI.IsMouseOn(friendPlayerListBox) + && PlayerInput.ScrollWheelSpeed != 0) + { + friendPlayerListBox.Deselect(); + } + + if (!friendPlayerListBox.Selected) + { + selectedFriendInfoFrame.Visible = false; + } + } + + if (eosConfigButton != null) + { + bool eosConfigAccessible = GameMain.NetworkMember == null; + if (eosConfigAccessible != eosConfigButton.Enabled) + { + eosConfigButton.Enabled = eosConfigAccessible; + eosConfigButton.Children.ForEach(c => c.Enabled = eosConfigAccessible); + eosConfigButton.ToolTip = eosConfigAccessible ? string.Empty : TextManager.Get("CantAccessEOSSettingsInMP"); + } + } + + var currentEosStatus = EosInterface.Core.CurrentStatus; + if (currentEosStatus != eosLastKnownStatus) + { + eosLastKnownStatus = currentEosStatus; + RefreshEosStatusText(); + } + + if (DateTime.Now < lastRefreshTime + refreshInterval) { return; } + + RefreshFriendList(); + } + + public void Dispose() + { + inviteHandler.Dispose(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs b/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs index 07da313e3..c3a929ec9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/SpamServerFilter.cs @@ -61,7 +61,7 @@ namespace Barotrauma SpamServerFilterType.MessageEquals => CompareEquals(desc, value), SpamServerFilterType.MessageContains => CompareContains(desc, value), - SpamServerFilterType.Endpoint => info.Endpoint.StringRepresentation.Equals(value, StringComparison.OrdinalIgnoreCase), + SpamServerFilterType.Endpoint => info.Endpoints.First().StringRepresentation.Equals(value, StringComparison.OrdinalIgnoreCase), SpamServerFilterType.PlayerCountLarger => info.PlayerCount > parsedInt, SpamServerFilterType.PlayerCountExact => info.PlayerCount == parsedInt, @@ -299,7 +299,7 @@ These will hide all servers that have a discord.gg link in their name or descrip { try { - if (!t.TryGetResult(out IRestResponse remoteContentResponse)) { throw new Exception("Task did not return a valid result"); } + if (!t.TryGetResult(out IRestResponse? remoteContentResponse)) { throw new Exception("Task did not return a valid result"); } if (remoteContentResponse.StatusCode != HttpStatusCode.OK) { DebugConsole.AddWarning( diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/AuthTicket.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/AuthTicket.cs deleted file mode 100644 index 82fa24ac1..000000000 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/AuthTicket.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Barotrauma.Steam -{ - static partial class SteamManager - { - public static Steamworks.BeginAuthResult StartAuthSession(byte[] authTicketData, ulong clientSteamID) - { - if (!IsInitialized || !Steamworks.SteamClient.IsValid) return Steamworks.BeginAuthResult.ServerNotConnectedToSteam; - - DebugConsole.Log("SteamManager authenticating Steam client " + clientSteamID); - Steamworks.BeginAuthResult startResult = Steamworks.SteamUser.BeginAuthSession(authTicketData, clientSteamID); - if (startResult != Steamworks.BeginAuthResult.OK) - { - DebugConsole.Log("Authentication failed: failed to start auth session (" + startResult.ToString() + ")"); - } - - return startResult; - } - - public static void StopAuthSession(ulong clientSteamID) - { - if (!IsInitialized || !Steamworks.SteamClient.IsValid) return; - - DebugConsole.NewMessage("SteamManager ending auth session with Steam client " + clientSteamID); - Steamworks.SteamUser.EndAuthSession(clientSteamID); - } - } -} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/BulkDownloader.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/BulkDownloader.cs index e42bf4dd3..5d8783855 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/BulkDownloader.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/BulkDownloader.cs @@ -31,8 +31,8 @@ namespace Barotrauma.Steam t => { msgBox.Close(); - if (!t.TryGetResult(out IReadOnlyList items)) { return; } - + if (!t.TryGetResult(out IReadOnlyList? items)) { return; } + InitiateDownloads(items); }); } @@ -48,7 +48,7 @@ namespace Barotrauma.Steam t => { msgBox.Close(); - if (!t.TryGetResult(out Steamworks.Ugc.Item?[] itemsNullable)) { return; } + if (!t.TryGetResult(out Steamworks.Ugc.Item?[]? itemsNullable)) { return; } var items = itemsNullable .Where(it => it.HasValue) @@ -74,7 +74,7 @@ namespace Barotrauma.Steam .NotNone() .OfType() .Select(async id => await SteamManager.Workshop.GetItem(id.Value)))) - .Where(p => p.HasValue).Select(p => p ?? default).ToArray(); + .NotNone().ToArray(); } public static void InitiateDownloads(IReadOnlyList itemsToDownload, Action? onComplete = null) diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/Lobby.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/Lobby.cs index 90c86e709..873e8a9ec 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/Lobby.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/Lobby.cs @@ -1,6 +1,6 @@ using Barotrauma.Networking; using System; -using System.Globalization; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -26,6 +26,7 @@ namespace Barotrauma.Steam public static void CreateLobby(ServerSettings serverSettings) { + if (!SteamManager.IsInitialized) { return; } if (lobbyState != LobbyState.NotConnected) { return; } lobbyState = LobbyState.Creating; TaskPool.Add("CreateLobbyAsync", Steamworks.SteamMatchmaking.CreateLobbyAsync(serverSettings.MaxPlayers + 10), @@ -88,45 +89,35 @@ namespace Barotrauma.Steam return; } - var contentPackages = ContentPackageManager.EnabledPackages.All.Where(cp => cp.HasMultiplayerSyncedContent); + serverSettings.UpdateServerListInfo(SetServerListInfo); - currentLobby?.SetData("name", serverSettings.ServerName); - currentLobby?.SetData("playercount", (GameMain.Client?.ConnectedClients?.Count ?? 0).ToString()); - currentLobby?.SetData("maxplayernum", serverSettings.MaxPlayers.ToString()); - //currentLobby?.SetData("hostipaddress", lobbyIP); - string pingLocation = Steamworks.SteamNetworkingUtils.LocalPingLocation?.ToString(); - currentLobby?.SetData("pinglocation", pingLocation ?? ""); currentLobby?.SetData("lobbyowner", GetSteamId().TryUnwrap(out var steamId) ? steamId.StringRepresentation : throw new InvalidOperationException("Steamworks not initialized")); - currentLobby?.SetData("haspassword", serverSettings.HasPassword.ToString()); - currentLobby?.SetData("message", serverSettings.ServerMessageText); - currentLobby?.SetData("version", GameMain.Version.ToString()); - - currentLobby?.SetData("contentpackage", string.Join(",", contentPackages.Select(cp => cp.Name))); - currentLobby?.SetData("contentpackagehash", string.Join(",", contentPackages.Select(cp => cp.Hash.StringRepresentation))); - currentLobby?.SetData("contentpackageid", string.Join(",", contentPackages.Select(cp - => cp.UgcId.TryUnwrap(out var ugcId) ? ugcId.StringRepresentation : ""))); - currentLobby?.SetData("modeselectionmode", serverSettings.ModeSelectionMode.ToString()); - currentLobby?.SetData("subselectionmode", serverSettings.SubSelectionMode.ToString()); - currentLobby?.SetData("voicechatenabled", serverSettings.VoiceChatEnabled.ToString()); - currentLobby?.SetData("allowspectating", serverSettings.AllowSpectating.ToString()); - currentLobby?.SetData("allowrespawn", serverSettings.AllowRespawn.ToString()); - currentLobby?.SetData("karmaenabled", serverSettings.KarmaEnabled.ToString()); - currentLobby?.SetData("friendlyfireenabled", serverSettings.AllowFriendlyFire.ToString()); - currentLobby?.SetData("traitors", serverSettings.TraitorProbability.ToString(CultureInfo.InvariantCulture)); - currentLobby?.SetData("gamestarted", GameMain.Client.GameStarted.ToString()); - currentLobby?.SetData("playstyle", serverSettings.PlayStyle.ToString()); - currentLobby?.SetData("gamemode", GameMain.NetLobbyScreen?.SelectedMode?.Identifier.Value ?? ""); - currentLobby?.SetData("language", serverSettings.Language.ToString()); - if (GameMain.NetLobbyScreen?.SelectedSub != null) + if (EosInterface.IdQueries.GetLoggedInPuids() is { Length: > 0 } puids) { - currentLobby?.SetData("submarine", GameMain.NetLobbyScreen.SelectedSub.Name); + currentLobby?.SetData("EosEndpoint", puids[0].Value); } - + DebugConsole.Log("Lobby updated!"); } + + private static void SetServerListInfo(Identifier key, object value) + { + switch (value) + { + case IEnumerable contentPackages: + currentLobby?.SetData("contentpackage", contentPackages.Select(p => p.Name).JoinEscaped(',')); + currentLobby?.SetData("contentpackagehash", contentPackages.Select(p => p.Hash.StringRepresentation).JoinEscaped(',')); + currentLobby?.SetData("contentpackageid", contentPackages + .Select(p => p.UgcId.Select(ugcId => ugcId.StringRepresentation).Fallback("")) + .JoinEscaped(',')); + return; + } + + currentLobby?.SetData(key.Value.ToLowerInvariant(), value.ToString()); + } public static void LeaveLobby() { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs index 44506e13a..c2b404b07 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/SteamManager.cs @@ -42,6 +42,9 @@ namespace Barotrauma.Steam } Steamworks.SteamNetworkingUtils.OnDebugOutput += LogSteamworksNetworking; + + // Needed to detect invites for social overlay + Steamworks.SteamFriends.ListenForFriendsMessages = true; } catch (DllNotFoundException) { @@ -145,10 +148,5 @@ namespace Barotrauma.Steam Steamworks.SteamFriends.OpenWebOverlay(url); return true; } - - public static void OverlayProfile(SteamId steamId) - { - OverlayCustomUrl($"https://steamcommunity.com/profiles/{steamId.Value}"); - } } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs index dd21a0e2a..f2d9e207e 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/Workshop.cs @@ -195,7 +195,7 @@ namespace Barotrauma.Steam modProject.Save(stagingFileListPath); } - public static async Task CreateLocalCopy(ContentPackage contentPackage) + public static async Task> CreateLocalCopy(ContentPackage contentPackage) { await Task.Yield(); @@ -234,7 +234,7 @@ namespace Barotrauma.Steam RefreshLocalMods(); - return ContentPackageManager.LocalPackages.FirstOrDefault(p => p.UgcId == contentPackage.UgcId); + return ContentPackageManager.LocalPackages.FirstOrNone(p => p.UgcId == contentPackage.UgcId); } private struct InstallWaiter diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/BBCode.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/BBCode.cs index 08c6ace39..36f8baaf6 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/BBCode.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/BBCode.cs @@ -196,9 +196,9 @@ namespace Barotrauma.Steam SteamManager.Workshop.GetItemAsap(workshopItem.Id.Value, withLongDescription: true), t => { - if (!t.TryGetResult(out Steamworks.Ugc.Item? workshopItemWithDescription)) { return; } + if (!t.TryGetResult(out Option workshopItemWithDescription)) { return; } - bbCode = workshopItemWithDescription?.Description ?? ""; + bbCode = workshopItemWithDescription.TryUnwrap(out var item) ? (item.Description ?? "") : ""; forceReset(); }); diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs index 1089221b1..2feff3989 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/InstalledTab.cs @@ -21,14 +21,14 @@ namespace Barotrauma.Steam private readonly Action onInstalledInfoButtonHit; private readonly GUITextBox modsListFilter; private readonly Dictionary modsListFilterTickboxes; - private readonly GUIButton bulkUpdateButton; + private readonly Option bulkUpdateButtonOption; private GUIComponent? draggedElement = null; private GUIListBox? draggedElementOrigin = null; private void UpdateSubscribedModInstalls() { - if (!SteamManager.IsInitialized) { return; } + if (!EnableWorkshopSupport) { return; } uint numSubscribedMods = SteamManager.GetNumSubscribedItems(); if (numSubscribedMods == memSubscribedModCount) { return; } @@ -171,7 +171,7 @@ namespace Barotrauma.Steam out Action onInstalledInfoButtonHit, out GUITextBox modsListFilter, out Dictionary modsListFilterTickboxes, - out GUIButton bulkUpdateButton) + out Option bulkUpdateButton) { GUIFrame content = CreateNewContentFrame(Tab.InstalledMods); @@ -233,18 +233,22 @@ namespace Barotrauma.Steam }, ToolTip = TextManager.Get("RefreshModLists") }; - bulkUpdateButton - = new GUIButton( - new RectTransform(Vector2.One, topRightButtons.RectTransform, scaleBasis: ScaleBasis.BothHeight), - text: "", style: "GUIUpdateButton") - { - OnClicked = (b, o) => + + bulkUpdateButton = EnableWorkshopSupport + ? Option.Some( + new GUIButton( + new RectTransform(Vector2.One, topRightButtons.RectTransform, scaleBasis: ScaleBasis.BothHeight), + text: "", style: "GUIUpdateButton") { - BulkDownloader.PrepareUpdates(); - return false; - }, - Enabled = false - }; + OnClicked = (b, + o) => + { + BulkDownloader.PrepareUpdates(); + return false; + }, + Enabled = false + }) + : Option.None; padTopRight(width: 0.1f); var (left, center, right) = CreateSidebars(mainLayout, centerWidth: 0.05f, leftWidth: 0.475f, rightWidth: 0.475f, height: 0.8f); @@ -405,10 +409,13 @@ namespace Barotrauma.Steam CanBeFocused = false }; } - - addFilterTickbox(Filter.ShowLocal, "WorkshopMenu.EditButton", selected: true); - addFilterTickbox(Filter.ShowWorkshop, "WorkshopMenu.DownloadedIcon", selected: true); - addFilterTickbox(Filter.ShowPublished, "WorkshopMenu.PublishedIcon", selected: true); + + if (EnableWorkshopSupport) + { + addFilterTickbox(Filter.ShowLocal, "WorkshopMenu.EditButton", selected: true); + addFilterTickbox(Filter.ShowWorkshop, "WorkshopMenu.DownloadedIcon", selected: true); + addFilterTickbox(Filter.ShowPublished, "WorkshopMenu.PublishedIcon", selected: true); + } addFilterTickbox(Filter.ShowOnlySubs, null, selected: false); addFilterTickbox(Filter.ShowOnlyItemAssemblies, null, selected: false); @@ -487,14 +494,23 @@ namespace Barotrauma.Steam var iconBtn = guiItem.GetChild()?.GetAllChildren().Last(); bool matches = false; - matches |= modsListFilterTickboxes[Filter.ShowLocal].Selected - && ContentPackageManager.LocalPackages.Contains(p); - matches |= modsListFilterTickboxes[Filter.ShowPublished].Selected - && (ContentPackageManager.WorkshopPackages.Contains(p) - && iconBtn?.Style?.Identifier == "WorkshopMenu.PublishedIcon"); - matches |= modsListFilterTickboxes[Filter.ShowWorkshop].Selected - && (ContentPackageManager.WorkshopPackages.Contains(p) - && iconBtn?.Style?.Identifier != "WorkshopMenu.PublishedIcon"); + + if (EnableWorkshopSupport) + { + matches |= modsListFilterTickboxes[Filter.ShowLocal].Selected + && ContentPackageManager.LocalPackages.Contains(p); + + matches |= modsListFilterTickboxes[Filter.ShowPublished].Selected + && (ContentPackageManager.WorkshopPackages.Contains(p) + && iconBtn?.Style?.Identifier == "WorkshopMenu.PublishedIcon"); + matches |= modsListFilterTickboxes[Filter.ShowWorkshop].Selected + && (ContentPackageManager.WorkshopPackages.Contains(p) + && iconBtn?.Style?.Identifier != "WorkshopMenu.PublishedIcon"); + } + else + { + matches = true; + } if (modsListFilterTickboxes[Filter.ShowOnlySubs].Selected && modsListFilterTickboxes[Filter.ShowOnlyItemAssemblies].Selected @@ -524,17 +540,20 @@ namespace Barotrauma.Steam TaskPool.Add($"PrepareToShow{mod.UgcId}Info", SteamManager.Workshop.GetItem(workshopId.Value), t => { - if (!t.TryGetResult(out Steamworks.Ugc.Item? item)) { return; } - if (item is null) { return; } - onInstalledInfoButtonHit(item.Value); + if (!t.TryGetResult(out Option itemOption)) { return; } + if (!itemOption.TryUnwrap(out var item)) { return; } + onInstalledInfoButtonHit(item); }); } public void PopulateInstalledModLists(bool forceRefreshEnabled = false, bool refreshDisabled = true) { ViewingItemDetails = false; - bulkUpdateButton.Enabled = false; - bulkUpdateButton.ToolTip = ""; + if (bulkUpdateButtonOption.TryUnwrap(out var bulkUpdateButton)) + { + bulkUpdateButton.Enabled = false; + bulkUpdateButton.ToolTip = ""; + } ContentPackageManager.UpdateContentPackageList(); var corePackages = ContentPackageManager.CorePackages.ToArray(); @@ -583,7 +602,7 @@ namespace Barotrauma.Steam return false; } }; - if (!SteamManager.IsInitialized) + if (!EnableWorkshopSupport) { infoButton.Enabled = false; } @@ -599,8 +618,11 @@ namespace Barotrauma.Steam infoButton.CanBeSelected = true; infoButton.ApplyStyle(GUIStyle.ComponentStyles["WorkshopMenu.InfoButtonUpdate"]); infoButton.ToolTip = TextManager.Get("ViewModDetailsUpdateAvailable"); - bulkUpdateButton.Enabled = true; - bulkUpdateButton.ToolTip = TextManager.Get("ModUpdatesAvailable"); + if (bulkUpdateButtonOption.TryUnwrap(out var bulkUpdateButton)) + { + bulkUpdateButton.Enabled = true; + bulkUpdateButton.ToolTip = TextManager.Get("ModUpdatesAvailable"); + } }); } } @@ -705,7 +727,7 @@ namespace Barotrauma.Steam TaskPool.AddIfNotFound($"UnsubFromSelected", Task.WhenAll(workshopIds.Select(SteamManager.Workshop.GetItem)), t => { - if (!t.TryGetResult(out Steamworks.Ugc.Item?[] items)) { return; } + if (!t.TryGetResult(out Steamworks.Ugc.Item?[]? items)) { return; } items.ForEach(it => { if (!(it is { } item)) { return; } @@ -761,7 +783,7 @@ namespace Barotrauma.Steam SteamManager.Workshop.GetPublishedItems(), t => { - if (!t.TryGetResult(out ISet items)) { return; } + if (!t.TryGetResult(out ISet? items)) { return; } var ids = items.Select(it => it.Id).ToHashSet(); foreach (var child in enabledRegularModsList.Content.Children diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ItemList.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ItemList.cs index 779101b67..91038dab5 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ItemList.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/ItemList.cs @@ -176,6 +176,8 @@ namespace Barotrauma.Steam private void AddUnpublishedMods(ISet workshopItems) { + if (!selfModsListOption.TryUnwrap(out var selfModsList)) { return; } + //Users that don't have a proper license cannot publish Workshop items //(see https://partner.steamgames.com/doc/features/workshop#15) void clearWithMessage(LocalizedString message) @@ -347,7 +349,7 @@ namespace Barotrauma.Steam workshopItem.Subscribe(); TaskPool.Add($"DownloadSubscribedItem{workshopItem.Id}", SteamManager.Workshop.ForceRedownload(workshopItem), - t => { }); + TaskPool.IgnoredCallback); } else { diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs index b69efd9b9..8c9a12c07 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/MutableWorkshopMenu.cs @@ -27,7 +27,7 @@ namespace Barotrauma.Steam } public Tab CurrentTab { get; private set; } - + private readonly GUILayoutGroup tabber; private readonly Dictionary tabContents; @@ -36,11 +36,13 @@ namespace Barotrauma.Steam private CancellationTokenSource taskCancelSrc = new CancellationTokenSource(); private readonly HashSet itemThumbnails = new HashSet(); - private readonly GUIListBox popularModsList; - private readonly GUIListBox selfModsList; + private readonly Option popularModsListOption; + private readonly Option selfModsListOption; private uint memSubscribedModCount = 0; + private static bool EnableWorkshopSupport => SteamManager.IsInitialized; + public MutableWorkshopMenu(GUIFrame parent) : base(parent) { var mainLayout @@ -50,25 +52,34 @@ namespace Barotrauma.Steam AbsoluteSpacing = GUI.IntScale(4) }; - tabber = new GUILayoutGroup(new RectTransform((1.0f, 0.05f), mainLayout.RectTransform), isHorizontal: true) + Vector2 tabberSize = EnableWorkshopSupport ? (1.0f, 0.05f) : Vector2.Zero; + + tabber = new GUILayoutGroup(new RectTransform(tabberSize, mainLayout.RectTransform), isHorizontal: true) { Stretch = true }; tabContents = new Dictionary(); - new GUIButton(new RectTransform((1.0f, 0.05f), mainLayout.RectTransform, Anchor.BottomLeft), - style: "GUIButtonSmall", text: TextManager.Get("FindModsButton")) + if (EnableWorkshopSupport) { - OnClicked = (button, o) => + new GUIButton(new RectTransform((1.0f, 0.05f), mainLayout.RectTransform, Anchor.BottomLeft), + style: "GUIButtonSmall", text: TextManager.Get("FindModsButton")) { - SteamManager.OverlayCustomUrl($"https://steamcommunity.com/app/{SteamManager.AppID}/workshop/"); - return false; - } - }; + OnClicked = (button, o) => + { + SteamManager.OverlayCustomUrl($"https://steamcommunity.com/app/{SteamManager.AppID}/workshop/"); + return false; + } + }; + } + else + { + tabber.Visible = false; + } contentFrame = new GUIFrame(new RectTransform((1.0f, 0.95f), mainLayout.RectTransform), style: null); new GUICustomComponent(new RectTransform(Vector2.Zero, mainLayout.RectTransform), onUpdate: (f, component) => UpdateSubscribedModInstalls()); - + CreateInstalledModsTab( out enabledCoreDropdown, out enabledRegularModsList, @@ -76,9 +87,21 @@ namespace Barotrauma.Steam out onInstalledInfoButtonHit, out modsListFilter, out modsListFilterTickboxes, - out bulkUpdateButton); - CreatePopularModsTab(out popularModsList); - CreatePublishTab(out selfModsList); + out bulkUpdateButtonOption); + + if (EnableWorkshopSupport) + { + CreatePopularModsTab(out GUIListBox popularModList); + CreatePublishTab(out GUIListBox selfModsList); + + popularModsListOption = Option.Some(popularModList); + selfModsListOption = Option.Some(selfModsList); + } + else + { + popularModsListOption = Option.None; + selfModsListOption = Option.None; + } SelectTab(Tab.InstalledMods); } @@ -105,10 +128,10 @@ namespace Barotrauma.Steam case Tab.InstalledMods: PopulateInstalledModLists(); break; - case Tab.PopularMods: + case Tab.PopularMods when popularModsListOption.TryUnwrap(out var popularModsList): PopulateItemList(popularModsList, SteamManager.Workshop.GetPopularItems(), includeSubscribeButton: true); break; - case Tab.Publish: + case Tab.Publish when selfModsListOption.TryUnwrap(out var selfModsList): PopulateItemList(selfModsList, SteamManager.Workshop.GetPublishedItems(), includeSubscribeButton: false, onFill: AddUnpublishedMods); break; } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/PublishTab.cs b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/PublishTab.cs index b7a66a8b8..fe3bc6a7b 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/PublishTab.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Steam/WorkshopMenu/Mutable/PublishTab.cs @@ -88,17 +88,21 @@ namespace Barotrauma.Steam private void DeselectPublishedItem() { - var deselectCarrier = selfModsList.Parent.FindChild(c => c.UserData is ActionCarrier { Id: var id } && id == "deselect"); - Action? deselectAction = deselectCarrier.UserData is ActionCarrier { Action: var action } - ? action - : null; - deselectAction?.Invoke(); + if (selfModsListOption.TryUnwrap(out var selfModsList)) + { + var deselectCarrier = selfModsList.Parent.FindChild(c => c.UserData is ActionCarrier { Id: var id } && id == "deselect"); + Action? deselectAction = deselectCarrier.UserData is ActionCarrier { Action: var action } + ? action + : null; + deselectAction?.Invoke(); + } + SelectTab(Tab.Publish); } - + private static bool PackageMatchesItem(ContentPackage p, Steamworks.Ugc.Item workshopItem) => p.TryExtractSteamWorkshopId(out var workshopId) && workshopId.Value == workshopItem.Id; - + private void PopulatePublishTab(ItemOrPackage itemOrPackage, GUIFrame parentFrame) { ContentPackageManager.LocalPackages.Refresh(); @@ -226,9 +230,12 @@ namespace Barotrauma.Steam SteamManager.Workshop.GetItemAsap(workshopItem.Id.Value, withLongDescription: true), t => { - if (!t.TryGetResult(out Steamworks.Ugc.Item? itemWithDescription)) { return; } + if (!t.TryGetResult(out Option itemWithDescriptionOption)) { return; } - descriptionTextBox.Text = itemWithDescription?.Description ?? descriptionTextBox.Text; + descriptionTextBox.Text = + itemWithDescriptionOption.TryUnwrap(out var itemWithDescription) + ? itemWithDescription.Description ?? descriptionTextBox.Text + : descriptionTextBox.Text; descriptionTextBox.Deselect(); }); } @@ -296,7 +303,7 @@ namespace Barotrauma.Steam var fileInfoLabel = Label(rightBottom, "", GUIStyle.Font, heightScale: 1.0f); fileInfoLabel.TextAlignment = Alignment.CenterRight; - TaskPool.Add($"FileInfoLabel{workshopItem.Id}", GetModDirInfo(localPackage.Dir, fileInfoLabel), t => { }); + TaskPool.AddWithResult($"FileInfoLabel{workshopItem.Id}", GetModDirInfo(localPackage.Dir, fileInfoLabel), t => { }); GUILayoutGroup buttonLayout = new GUILayoutGroup(NewItemRectT(rightBottom), isHorizontal: true, childAnchor: Anchor.CenterRight); @@ -351,7 +358,7 @@ namespace Barotrauma.Steam buttons: new[] { TextManager.Get("Yes"), TextManager.Get("No") }); confirmDeletion.Buttons[0].OnClicked = (yesBuffer, o1) => { - TaskPool.Add($"Delete{workshopItem.Id}", Steamworks.SteamUGC.DeleteFileAsync(workshopItem.Id), + TaskPool.AddWithResult($"Delete{workshopItem.Id}", Steamworks.SteamUGC.DeleteFileAsync(workshopItem.Id), t => { SteamManager.Workshop.Uninstall(workshopItem); @@ -452,7 +459,7 @@ namespace Barotrauma.Steam } bool localCopyMade = false; - TaskPool.Add($"Create local copy {workshopItem.Title}", + TaskPool.AddWithResult($"Create local copy {workshopItem.Title}", SteamManager.Workshop.CreateLocalCopy(workshopCopy), (t) => { diff --git a/Barotrauma/BarotraumaClient/ClientSource/StoreIntegration/StoreIntegration.cs b/Barotrauma/BarotraumaClient/ClientSource/StoreIntegration/StoreIntegration.cs new file mode 100644 index 000000000..6a381ce47 --- /dev/null +++ b/Barotrauma/BarotraumaClient/ClientSource/StoreIntegration/StoreIntegration.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using Barotrauma.Steam; + +namespace Barotrauma; + +static class StoreIntegration +{ + public enum Store + { + None, + Steam, + Epic + } + + public static Store CurrentStore { get; private set; } = Store.None; + + public static void Init(ref string[] programArgs) + { +#if DEBUG + if (EosInterface.Login.ParseEgsExchangeCode(programArgs).IsNone()) + { + // If the dev tool is running on port 8730 with a credential of name localdev, + // we can ask it to give us an exchange code so we can test the launcher args parsing + try + { + var devAuthToolHttp = new HttpClient(); + devAuthToolHttp.BaseAddress = new UriBuilder(scheme: "http", host: "127.0.0.1", portNumber: 8730).Uri; + var response = devAuthToolHttp.Send(new HttpRequestMessage(HttpMethod.Get, "localdev/exchange_code")); + if (response.IsSuccessStatusCode) + { + string responseContent = response.Content.ReadAsStringAsync().Result; + var match = Regex.Match(input: responseContent, + @"\s*{\s*""exchange_code""\s*:\s*""([0-9a-fA-F]+)""\s*}\s*"); + if (match.Groups.Count > 1) + { + programArgs = programArgs.Concat(new[] + { + $"-AUTH_PASSWORD={match.Groups[1].Value}", + "-AUTH_TYPE=exchangecode" + }).ToArray(); + } + } + } + catch { /* do nothing */ } + } +#endif + if (EosInterface.Login.ParseEgsExchangeCode(programArgs).IsNone() && SteamManager.SteamworksLibExists) + { + // Didn't get EGS exchange code, assume we're on Steam + // and do not initialize EOS SDK until player consent is confirmed + SteamManager.Initialize(); + CurrentStore = Store.Steam; + } + else + { + // Got an EGS exchange code or Steamworks is not present in the files, + // assume we're on EGS and initialize EOS SDK immediately. + if (EosInterface.Core.Init(EosInterface.ApplicationCredentials.Client, enableOverlay: RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + .TryUnwrapFailure(out var initError)) + { + DebugConsole.ThrowError($"EOS failed to initialize: {initError}"); + } + CurrentStore = Store.Epic; + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/LimitLString.cs b/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/LimitLString.cs index 804c688ca..2e692bb81 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/LimitLString.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/LimitLString.cs @@ -19,12 +19,14 @@ namespace Barotrauma public override bool Loaded => nestedStr.Loaded; protected override bool MustRetrieveValue() { - return base.MustRetrieveValue() || cachedFont != font.Value || cachedFont.Size != font.Size; + return base.MustRetrieveValue() || cachedFont != font.Value || cachedFont?.Size != font.Size; } public override void RetrieveValue() { - cachedValue = ToolBox.LimitString(nestedStr.Value, font.Value, maxWidth); + cachedValue = font.Value != null + ? ToolBox.LimitString(nestedStr.Value, font.Value, maxWidth) + : nestedStr.Value; cachedFont = font.Value; UpdateLanguage(); } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/WrappedLString.cs b/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/WrappedLString.cs index f2cf800d2..686ef3728 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/WrappedLString.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Text/LocalizedString/WrappedLString.cs @@ -19,7 +19,10 @@ namespace Barotrauma public override bool Loaded => nestedStr.Loaded; public override void RetrieveValue() { - cachedValue = ToolBox.WrapText(nestedStr.Value, lineLength, font.GetFontForStr(nestedStr.Value), textScale); + cachedValue = + font.GetFontForStr(nestedStr.Value) is ScalableFont scalableFont + ? ToolBox.WrapText(nestedStr.Value, lineLength, scalableFont, textScale) + : nestedStr.Value; UpdateLanguage(); } } diff --git a/Barotrauma/BarotraumaClient/ClientSource/Utils/ConnectCommand.cs b/Barotrauma/BarotraumaClient/ClientSource/Utils/ConnectCommand.cs deleted file mode 100644 index fb86070c6..000000000 --- a/Barotrauma/BarotraumaClient/ClientSource/Utils/ConnectCommand.cs +++ /dev/null @@ -1,32 +0,0 @@ -#nullable enable -using Barotrauma.Networking; - -namespace Barotrauma -{ - readonly struct ConnectCommand - { - public readonly struct NameAndEndpoint - { - public readonly string ServerName; - public readonly Endpoint Endpoint; - - public NameAndEndpoint(string serverName, Endpoint endpoint) - { - ServerName = serverName; - Endpoint = endpoint; - } - } - - public readonly Either EndpointOrLobby; - - public ConnectCommand(string serverName, Endpoint endpoint) - { - EndpointOrLobby = new NameAndEndpoint(serverName, endpoint); - } - - public ConnectCommand(ulong lobbyId) - { - EndpointOrLobby = lobbyId; - } - } -} diff --git a/Barotrauma/BarotraumaClient/ClientSource/Utils/ToolBox.cs b/Barotrauma/BarotraumaClient/ClientSource/Utils/ToolBox.cs index e04dfc070..626db0eb9 100644 --- a/Barotrauma/BarotraumaClient/ClientSource/Utils/ToolBox.cs +++ b/Barotrauma/BarotraumaClient/ClientSource/Utils/ToolBox.cs @@ -1,4 +1,5 @@ -using Microsoft.Xna.Framework; +#nullable enable +using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -451,28 +452,6 @@ namespace Barotrauma public static string WrapText(string text, float lineLength, ScalableFont font, float textScale = 1.0f) => font.WrapText(text, lineLength / textScale); - public static Option ParseConnectCommand(string[] args) - { - if (args == null || args.Length < 2) { return Option.None(); } - - if (args[0].Equals("-connect", StringComparison.OrdinalIgnoreCase)) - { - if (args.Length < 3) { return Option.None(); } - if (!(Endpoint.Parse(args[2]).TryUnwrap(out var endpoint))) { return Option.None(); } - return Option.Some( - new ConnectCommand( - serverName: args[1], - endpoint: endpoint)); - } - else if (args[0].Equals("+connect_lobby", StringComparison.OrdinalIgnoreCase)) - { - return UInt64.TryParse(args[1], out var lobbyId) - ? Option.Some(new ConnectCommand(lobbyId)) - : Option.None(); - } - return Option.None(); - } - public static bool VersionNewerIgnoreRevision(Version a, Version b) { if (b.Major > a.Major) { return true; } diff --git a/Barotrauma/BarotraumaClient/LinuxClient.csproj b/Barotrauma/BarotraumaClient/LinuxClient.csproj index 3f7a3f89c..64c52d9e9 100644 --- a/Barotrauma/BarotraumaClient/LinuxClient.csproj +++ b/Barotrauma/BarotraumaClient/LinuxClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 Barotrauma @@ -17,38 +17,38 @@ - DEBUG;TRACE;CLIENT;LINUX;USE_STEAM + DEBUG;TRACE;CLIENT;LINUX x64 ..\bin\$(Configuration)Linux\ - TRACE;DEBUG;CLIENT;LINUX;X64;USE_STEAM + TRACE;DEBUG;CLIENT;LINUX;X64 x64 ..\bin\$(Configuration)Linux\ - TRACE;CLIENT;LINUX;USE_STEAM + TRACE;CLIENT;LINUX x64 ..\bin\$(Configuration)Linux\ - TRACE;CLIENT;LINUX;USE_STEAM;UNSTABLE + TRACE;CLIENT;LINUX;UNSTABLE x64 ..\bin\$(Configuration)Linux\ true - TRACE;CLIENT;LINUX;X64;USE_STEAM + TRACE;CLIENT;LINUX;X64 x64 ..\bin\$(Configuration)Linux\ - TRACE;CLIENT;LINUX;X64;USE_STEAM;UNSTABLE + TRACE;CLIENT;LINUX;X64;UNSTABLE x64 ..\bin\$(Configuration)Linux\ true @@ -62,7 +62,6 @@ - @@ -136,6 +135,11 @@ + + + + + @@ -201,8 +205,10 @@ - linux-x64 + linux-x64 + Linux + \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/MacClient.csproj b/Barotrauma/BarotraumaClient/MacClient.csproj index df3355c96..8effce6b9 100644 --- a/Barotrauma/BarotraumaClient/MacClient.csproj +++ b/Barotrauma/BarotraumaClient/MacClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 Barotrauma @@ -17,26 +17,26 @@ - TRACE;CLIENT;OSX;USE_STEAM;DEBUG;NETCOREAPP;NETCOREAPP3_0 + TRACE;CLIENT;OSX;DEBUG;NETCOREAPP;NETCOREAPP3_0 x64 ..\bin\$(Configuration)Mac - TRACE;DEBUG;CLIENT;OSX;X64;USE_STEAM + TRACE;DEBUG;CLIENT;OSX;X64 x64 ..\bin\$(Configuration)Mac\ - TRACE;CLIENT;OSX;USE_STEAM;RELEASE;NETCOREAPP;NETCOREAPP3_0 + TRACE;CLIENT;OSX;RELEASE;NETCOREAPP;NETCOREAPP3_0 x64 ..\bin\$(Configuration)Mac - TRACE;CLIENT;OSX;USE_STEAM;RELEASE;NETCOREAPP;NETCOREAPP3_0;UNSTABLE + TRACE;CLIENT;OSX;RELEASE;NETCOREAPP;NETCOREAPP3_0;UNSTABLE x64 ..\bin\$(Configuration)Mac @@ -44,13 +44,13 @@ - TRACE;CLIENT;OSX;X64;USE_STEAM + TRACE;CLIENT;OSX;X64 x64 ..\bin\$(Configuration)Mac\ - TRACE;CLIENT;OSX;X64;USE_STEAM;UNSTABLE + TRACE;CLIENT;OSX;X64;UNSTABLE x64 ..\bin\$(Configuration)Mac\ true @@ -64,7 +64,6 @@ - @@ -140,6 +139,10 @@ PreserveNewest + + + + @@ -206,8 +209,10 @@ - osx-x64 + osx-x64 + MacOS + \ No newline at end of file diff --git a/Barotrauma/BarotraumaClient/WindowsClient.csproj b/Barotrauma/BarotraumaClient/WindowsClient.csproj index 4cd3790e1..3574cc843 100644 --- a/Barotrauma/BarotraumaClient/WindowsClient.csproj +++ b/Barotrauma/BarotraumaClient/WindowsClient.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 Barotrauma @@ -18,13 +18,13 @@ - DEBUG;TRACE;CLIENT;WINDOWS;USE_STEAM + DEBUG;TRACE;CLIENT;WINDOWS x64 ..\bin\$(Configuration)Windows\ - TRACE;DEBUG;CLIENT;WINDOWS;X64;USE_STEAM + TRACE;DEBUG;CLIENT;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -33,20 +33,20 @@ - TRACE;CLIENT;WINDOWS;USE_STEAM + TRACE;CLIENT;WINDOWS x64 ..\bin\$(Configuration)Windows\ - TRACE;CLIENT;WINDOWS;USE_STEAM + TRACE;CLIENT;WINDOWS x64 ..\bin\$(Configuration)Windows\ true - TRACE;CLIENT;WINDOWS;X64;USE_STEAM + TRACE;CLIENT;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -54,7 +54,7 @@ - TRACE;CLIENT;WINDOWS;X64;USE_STEAM + TRACE;CLIENT;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -70,7 +70,6 @@ - @@ -168,6 +167,11 @@ + + + + + @@ -233,8 +237,10 @@ - win-x64 + win-x64 + Win64 + \ No newline at end of file diff --git a/Barotrauma/BarotraumaServer/LinuxServer.csproj b/Barotrauma/BarotraumaServer/LinuxServer.csproj index 4f1bb7e53..20678b562 100644 --- a/Barotrauma/BarotraumaServer/LinuxServer.csproj +++ b/Barotrauma/BarotraumaServer/LinuxServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -17,38 +17,38 @@ - DEBUG;TRACE;SERVER;LINUX;USE_STEAM + DEBUG;TRACE;SERVER;LINUX x64 ..\bin\$(Configuration)Linux\ - TRACE;DEBUG;SERVER;LINUX;X64;USE_STEAM + TRACE;DEBUG;SERVER;LINUX;X64 x64 ..\bin\$(Configuration)Linux\ - TRACE;SERVER;LINUX;USE_STEAM + TRACE;SERVER;LINUX x64 ..\bin\$(Configuration)Linux\ - TRACE;SERVER;LINUX;USE_STEAM + TRACE;SERVER;LINUX x64 ..\bin\$(Configuration)Linux\ true - TRACE;SERVER;LINUX;X64;USE_STEAM + TRACE;SERVER;LINUX;X64 x64 ..\bin\$(Configuration)Linux\ - TRACE;SERVER;LINUX;X64;USE_STEAM + TRACE;SERVER;LINUX;X64 x64 ..\bin\$(Configuration)Linux\ true @@ -56,7 +56,6 @@ - @@ -80,6 +79,11 @@ + + + + + @@ -145,4 +149,10 @@ + + linux-x64 + Linux + + + diff --git a/Barotrauma/BarotraumaServer/MacServer.csproj b/Barotrauma/BarotraumaServer/MacServer.csproj index 43e565bd8..4d80f2da0 100644 --- a/Barotrauma/BarotraumaServer/MacServer.csproj +++ b/Barotrauma/BarotraumaServer/MacServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -17,7 +17,7 @@ - TRACE;SERVER;OSX;USE_STEAM;DEBUG;NETCOREAPP;NETCOREAPP3_0 + TRACE;SERVER;OSX;DEBUG;NETCOREAPP;NETCOREAPP3_0 x64 ..\bin\DebugMac true @@ -25,20 +25,20 @@ - TRACE;DEBUG;SERVER;OSX;X64;USE_STEAM + TRACE;DEBUG;SERVER;OSX;X64 x64 ..\bin\$(Configuration)Mac\ - TRACE;SERVER;OSX;USE_STEAM;RELEASE;NETCOREAPP;NETCOREAPP3_0 + TRACE;SERVER;OSX;RELEASE;NETCOREAPP;NETCOREAPP3_0 x64 ..\bin\ReleaseMac - TRACE;SERVER;OSX;USE_STEAM;RELEASE;NETCOREAPP;NETCOREAPP3_0;UNSTABLE + TRACE;SERVER;OSX;RELEASE;NETCOREAPP;NETCOREAPP3_0;UNSTABLE x64 ..\bin\ReleaseMac @@ -46,13 +46,13 @@ - TRACE;SERVER;OSX;X64;USE_STEAM + TRACE;SERVER;OSX;X64 x64 ..\bin\$(Configuration)Mac\ - TRACE;SERVER;OSX;X64;USE_STEAM;UNSTABLE + TRACE;SERVER;OSX;X64;UNSTABLE x64 ..\bin\$(Configuration)Mac\ true @@ -60,7 +60,6 @@ - @@ -86,7 +85,11 @@ - + + + + + @@ -151,4 +154,10 @@ + + osx-x64 + MacOS + + + diff --git a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs index fc368dace..4002d6ebe 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Characters/CharacterInfo.cs @@ -72,7 +72,7 @@ namespace Barotrauma msg.WriteString(ragdollFileName); msg.WriteIdentifier(HumanPrefabIds.NpcIdentifier); msg.WriteIdentifier(MinReputationToHire.factionId); - if (MinReputationToHire.factionId != default) + if (!MinReputationToHire.factionId.IsEmpty) { msg.WriteSingle(MinReputationToHire.reputation); } diff --git a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs index aebcc26a5..21758979c 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/DebugConsole.cs @@ -37,10 +37,8 @@ namespace Barotrauma NewMessage("Client \"" + client.Name + "\" attempted to use the command \"" + Names[0] + "\". Cheats must be enabled using \"enablecheats\" before the command can be used.", Color.Red); GameMain.Server.SendConsoleMessage("You need to enable cheats using the command \"enablecheats\" before you can use the command \"" + Names[0] + "\".", client, Color.Red); -#if USE_STEAM NewMessage("Enabling cheats will disable Steam achievements during this play session.", Color.Red); GameMain.Server.SendConsoleMessage("Enabling cheats will disable Steam achievements during this play session.", client, Color.Red); -#endif return; } @@ -1062,21 +1060,17 @@ namespace Barotrauma commands.Add(new Command("enablecheats", "enablecheats: Enables cheat commands and disables Steam achievements during this play session.", (string[] args) => { CheatsEnabled = true; - SteamAchievementManager.CheatsEnabled = true; + AchievementManager.CheatsEnabled = true; NewMessage("Enabled cheat commands.", Color.Red); -#if USE_STEAM NewMessage("Steam achievements have been disabled during this play session.", Color.Red); -#endif GameMain.Server?.UpdateCheatsEnabled(); })); AssignOnClientRequestExecute("enablecheats", (client, cursorPos, args) => { CheatsEnabled = true; - SteamAchievementManager.CheatsEnabled = true; + AchievementManager.CheatsEnabled = true; NewMessage("Cheat commands have been enabled by \"" + client.Name + "\".", Color.Red); -#if USE_STEAM NewMessage("Steam achievements have been disabled during this play session.", Color.Red); -#endif GameMain.Server?.UpdateCheatsEnabled(); }); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/EventLogAction.cs b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/EventLogAction.cs index 86d0297f6..b6dac422e 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/EventLogAction.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Events/EventActions/EventLogAction.cs @@ -20,7 +20,7 @@ partial class EventLogAction : EventAction if (target is Character character) { var ownerClient = GameMain.Server.ConnectedClients.Find(c => c.Character == character); - if (ownerClient != null && eventLog != null) + if (ownerClient != null) { targetClients.Add(ownerClient); } @@ -38,7 +38,7 @@ partial class EventLogAction : EventAction } else { - if (eventLog != null && eventLog.TryAddEntry(ParentEvent.Prefab.Identifier, Id, displayText, GameMain.Server.ConnectedClients) && ShowInServerLog) + if (eventLog.TryAddEntry(ParentEvent.Prefab.Identifier, Id, displayText, GameMain.Server.ConnectedClients) && ShowInServerLog) { Log(targetClients: null); } diff --git a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs index 1be65ac9c..c62784177 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/GameMain.cs @@ -84,8 +84,21 @@ namespace Barotrauma Console.WriteLine("Loading game settings"); GameSettings.Init(); - Console.WriteLine("Initializing SteamManager"); - SteamManager.Initialize(); + //no owner key = dedicated server + if (!CommandLineArgs.Any(a => a.Trim().Equals("-ownerkey", StringComparison.OrdinalIgnoreCase))) + { + Console.WriteLine("Initializing SteamManager"); + SteamManager.Initialize(); + + if (!SteamManager.SteamworksLibExists) + { + Console.WriteLine("Initializing EosManager"); + if (EosInterface.Core.Init(EosInterface.ApplicationCredentials.Server, enableOverlay: false).TryUnwrapFailure(out var initError)) + { + Console.WriteLine($"EOS failed to initialize: {initError}"); + } + } + } //TODO: figure out how consent is supposed to work for servers //Console.WriteLine("Initializing GameAnalytics"); @@ -137,8 +150,8 @@ namespace Barotrauma bool enableUpnp = false; int maxPlayers = 10; - Option ownerKey = Option.None(); - Option steamId = Option.None(); + Option ownerKey = Option.None; + Option ownerEndpoint = Option.None; XDocument doc = XMLExtensions.TryLoadXml(ServerSettings.SettingsFile); if (doc?.Root == null) @@ -207,8 +220,8 @@ namespace Barotrauma } i++; break; - case "-steamid": - steamId = SteamId.Parse(CommandLineArgs[i + 1]); + case "-endpoint": + ownerEndpoint = P2PEndpoint.Parse(CommandLineArgs[i + 1]); i++; break; case "-pipes": @@ -227,7 +240,7 @@ namespace Barotrauma enableUpnp, maxPlayers, ownerKey, - steamId); + ownerEndpoint); Server.StartServer(); for (int i = 0; i < CommandLineArgs.Length; i++) @@ -323,6 +336,7 @@ namespace Barotrauma Server.Update((float)Timing.Step); if (Server == null) { break; } SteamManager.Update((float)Timing.Step); + EosInterface.Core.Update(); TaskPool.Update(); CoroutineManager.Update(paused: false, (float)Timing.Step); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs index 8742d17eb..5ffb59bc7 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/BanList.cs @@ -174,6 +174,22 @@ namespace Barotrauma.Networking return bannedPlayer != null; } + public bool IsBanned(AccountInfo accountInfo, out string reason) + { + if (accountInfo.AccountId.TryUnwrap(out var accountId) && IsBanned(accountId, out reason)) + { + return true; + } + + foreach (var otherId in accountInfo.OtherMatchingIds) + { + if (IsBanned(otherId, out reason)) { return true; } + } + + reason = ""; + return false; + } + public void BanPlayer(string name, Endpoint endpoint, string reason, TimeSpan? duration) => BanPlayer(name, endpoint.Address, reason, duration); @@ -305,7 +321,7 @@ namespace Barotrauma.Networking else { outMsg.WriteBoolean(false); outMsg.WritePadBits(); - outMsg.WriteString(((SteamId)bannedPlayer.AddressOrAccountId).StringRepresentation); + outMsg.WriteString(((AccountId)bannedPlayer.AddressOrAccountId).StringRepresentation); } } } diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs index cede18f2d..af254550f 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs @@ -52,7 +52,7 @@ namespace Barotrauma.Networking private DateTime refreshMasterTimer; private readonly TimeSpan refreshMasterInterval = new TimeSpan(0, 0, 60); - private bool registeredToMaster; + private bool registeredToSteamMaster; private DateTime roundStartTime; @@ -121,7 +121,7 @@ namespace Barotrauma.Networking public NetworkConnection OwnerConnection { get; private set; } private readonly Option ownerKey; - private readonly Option ownerSteamId; + private readonly Option ownerEndpoint; public GameServer( string name, @@ -132,7 +132,7 @@ namespace Barotrauma.Networking bool attemptUPnP, int maxPlayers, Option ownerKey, - Option ownerSteamId) + Option ownerEndpoint) { if (name.Length > NetConfig.ServerNameMaxLength) { @@ -150,7 +150,7 @@ namespace Barotrauma.Networking this.ownerKey = ownerKey; - this.ownerSteamId = ownerSteamId; + this.ownerEndpoint = ownerEndpoint; entityEventManager = new ServerEntityEventManager(this); } @@ -165,16 +165,18 @@ namespace Barotrauma.Networking OnInitializationComplete, GameMain.Instance.CloseServer, OnOwnerDetermined); - - if (ownerSteamId.TryUnwrap(out var steamId)) + + if (ownerEndpoint.TryUnwrap(out var endpoint)) { - Log("Using SteamP2P networking.", ServerLog.MessageType.ServerMessage); - serverPeer = new SteamP2PServerPeer(steamId, ownerKey.Fallback(0), ServerSettings, callbacks); + Log("Using P2P networking.", ServerLog.MessageType.ServerMessage); + serverPeer = new P2PServerPeer(endpoint, ownerKey.Fallback(0), ServerSettings, callbacks); } else { - Log("Using Lidgren networking. Manual port forwarding may be required. If players cannot connect to the server, you may want to use the in-game hosting menu (which uses SteamP2P networking and does not require port forwarding).", ServerLog.MessageType.ServerMessage); + Log("Using Lidgren networking. Manual port forwarding may be required. If players cannot connect to the server, you may want to use the in-game hosting menu (which uses Steamworks and EOS networking and does not require port forwarding).", ServerLog.MessageType.ServerMessage); serverPeer = new LidgrenServerPeer(ownerKey, ServerSettings, callbacks); + registeredToSteamMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic); + Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings); } FileSender = new FileSender(serverPeer, MsgConstants.MTU); @@ -187,13 +189,6 @@ namespace Barotrauma.Networking VoipServer = new VoipServer(serverPeer); - if (serverPeer is LidgrenServerPeer) - { -#if USE_STEAM - registeredToMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic); -#endif - } - Log("Server started", ServerLog.MessageType.ServerMessage); GameMain.NetLobbyScreen.Select(); @@ -648,20 +643,23 @@ namespace Barotrauma.Networking updateTimer = DateTime.Now + UpdateInterval; } - if (registeredToMaster && (DateTime.Now > refreshMasterTimer || ServerSettings.ServerDetailsChanged)) + if (DateTime.Now > refreshMasterTimer || ServerSettings.ServerDetailsChanged) { - if (GameSettings.CurrentConfig.UseSteamMatchmaking) + if (registeredToSteamMaster) { bool refreshSuccessful = SteamManager.RefreshServerDetails(this); if (GameSettings.CurrentConfig.VerboseLogging) { Log(refreshSuccessful ? - "Refreshed server info on the server list." : - "Refreshing server info on the server list failed.", ServerLog.MessageType.ServerMessage); + "Refreshed server info on the Steam server list." : + "Refreshing server info on the Steam server list failed.", ServerLog.MessageType.ServerMessage); } } - refreshMasterTimer = DateTime.Now + refreshMasterInterval; + + Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings); + ServerSettings.ServerDetailsChanged = false; + refreshMasterTimer = DateTime.Now + refreshMasterInterval; } } @@ -3031,8 +3029,6 @@ namespace Barotrauma.Networking client.WaitForNextRoundRespawn = null; client.InGame = false; - if (client.AccountId.TryUnwrap(out var steamId)) { SteamManager.StopAuthSession(steamId); } - var previousPlayer = previousPlayers.Find(p => p.MatchesClient(client)); if (previousPlayer == null) { @@ -3356,7 +3352,7 @@ namespace Barotrauma.Networking msg.WriteByte((byte)ServerPacketHeader.FILE_TRANSFER); msg.WriteByte((byte)FileTransferMessageType.Cancel); msg.WriteByte((byte)transfer.ID); - serverPeer.Send(msg, transfer.Connection, DeliveryMethod.ReliableOrdered); + serverPeer.Send(msg, transfer.Connection, DeliveryMethod.Reliable); } public void UpdateVoteStatus(bool checkActiveVote = true) @@ -3542,13 +3538,13 @@ namespace Barotrauma.Networking } } - public void IncrementStat(Character character, Identifier achievementIdentifier, int amount) + public void IncrementStat(Character character, AchievementStat stat, int amount) { foreach (Client client in connectedClients) { if (client.Character == character) { - IncrementStat(client, achievementIdentifier, amount); + IncrementStat(client, stat, amount); return; } } @@ -3562,19 +3558,17 @@ namespace Barotrauma.Networking IWriteMessage msg = new WriteOnlyMessage(); msg.WriteByte((byte)ServerPacketHeader.ACHIEVEMENT); msg.WriteIdentifier(achievementIdentifier); - msg.WriteInt32(0); serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable); } - public void IncrementStat(Client client, Identifier achievementIdentifier, int amount) + public void IncrementStat(Client client, AchievementStat stat, int amount) { - if (client.GivenAchievements.Contains(achievementIdentifier)) { return; } - IWriteMessage msg = new WriteOnlyMessage(); - msg.WriteByte((byte)ServerPacketHeader.ACHIEVEMENT); - msg.WriteIdentifier(achievementIdentifier); - msg.WriteInt32(amount); + msg.WriteByte((byte)ServerPacketHeader.ACHIEVEMENT_STAT); + + INetSerializableStruct incrementedStat = new NetIncrementedStat(stat, amount); + incrementedStat.Write(msg); serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable); } @@ -3582,7 +3576,7 @@ namespace Barotrauma.Networking public void SendTraitorMessage(WriteOnlyMessage msg, Client client) { if (client == null) { return; }; - serverPeer.Send(msg, client.Connection, DeliveryMethod.ReliableOrdered); + serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable); } public void UpdateCheatsEnabled() diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs index eaaf5e1dc..1f5c680b0 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/LidgrenServerPeer.cs @@ -1,24 +1,26 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Net; using System.Linq; +using Barotrauma.Extensions; using Barotrauma.Steam; using Lidgren.Network; namespace Barotrauma.Networking { - internal sealed class LidgrenServerPeer : ServerPeer + internal sealed class LidgrenServerPeer : ServerPeer { private readonly NetPeerConfiguration netPeerConfiguration; + private ImmutableDictionary? authenticators; private NetServer? netServer; private readonly List incomingLidgrenMessages; - public LidgrenServerPeer(Option ownKey, ServerSettings settings, Callbacks callbacks) : base(callbacks) + public LidgrenServerPeer(Option ownKey, ServerSettings settings, Callbacks callbacks) : base(callbacks, settings) { - serverSettings = settings; - + authenticators = null; netServer = null; netPeerConfiguration = new NetPeerConfiguration("barotrauma") @@ -41,9 +43,6 @@ namespace Barotrauma.Networking netPeerConfiguration.EnableMessageType(NetIncomingMessageType.ConnectionApproval); - connectedClients = new List(); - pendingClients = new List(); - incomingLidgrenMessages = new List(); ownerKey = ownKey; @@ -53,6 +52,8 @@ namespace Barotrauma.Networking { if (netServer != null) { return; } + authenticators = Authenticator.GetAuthenticatorsForHost(Option.None); + incomingLidgrenMessages.Clear(); netServer = new NetServer(netPeerConfiguration); @@ -80,7 +81,7 @@ namespace Barotrauma.Networking for (int i = connectedClients.Count - 1; i >= 0; i--) { - Disconnect(connectedClients[i], PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown)); + Disconnect(connectedClients[i].Connection, PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown)); } netServer.Shutdown(PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown).ToLidgrenStringRepresentation()); @@ -90,8 +91,6 @@ namespace Barotrauma.Networking netServer = null; - Steamworks.SteamServer.OnValidateAuthTicketResponse -= OnAuthChange; - callbacks.OnShutdown.Invoke(); } @@ -165,9 +164,10 @@ namespace Barotrauma.Networking ToolBox.ThrowIfNull(netPeerConfiguration); netServer.UPnP.ForwardPort(netPeerConfiguration.Port, "barotrauma"); -#if USE_STEAM - netServer.UPnP.ForwardPort(serverSettings.QueryPort, "barotrauma"); -#endif + if (SteamManager.IsInitialized) + { + netServer.UPnP.ForwardPort(serverSettings.QueryPort, "barotrauma"); + } } private bool DiscoveringUPnP() @@ -199,7 +199,7 @@ namespace Barotrauma.Networking return; } - PendingClient? pendingClient = pendingClients.Find(c => c.Connection is LidgrenConnection l && l.NetConnection == inc.SenderConnection); + PendingClient? pendingClient = pendingClients.Find(c => c.Connection.NetConnection == inc.SenderConnection); if (pendingClient is null) { @@ -214,7 +214,7 @@ namespace Barotrauma.Networking { if (netServer == null) { return; } - PendingClient? pendingClient = pendingClients.Find(c => c.Connection is LidgrenConnection l && l.NetConnection == lidgrenMsg.SenderConnection); + PendingClient? pendingClient = pendingClients.Find(c => c.Connection.NetConnection == lidgrenMsg.SenderConnection); IReadMessage inc = lidgrenMsg.ToReadMessage(); @@ -226,7 +226,7 @@ namespace Barotrauma.Networking } else if (!packetHeader.IsConnectionInitializationStep()) { - if (connectedClients.Find(c => c is LidgrenConnection l && l.NetConnection == lidgrenMsg.SenderConnection) is not LidgrenConnection conn) + if (connectedClients.Find(c => c.Connection.NetConnection == lidgrenMsg.SenderConnection) is not { Connection: LidgrenConnection conn }) { if (pendingClient != null) { @@ -263,7 +263,7 @@ namespace Barotrauma.Networking switch (inc.SenderConnection.Status) { case NetConnectionStatus.Disconnected: - LidgrenConnection? conn = connectedClients.Cast().FirstOrDefault(c => c.NetConnection == inc.SenderConnection); + LidgrenConnection? conn = connectedClients.Select(c => c.Connection).FirstOrDefault(c => c.NetConnection == inc.SenderConnection); if (conn != null) { if (conn == OwnerConnection) @@ -290,12 +290,7 @@ namespace Barotrauma.Networking } } - public override void InitializeSteamServerCallbacks() - { - Steamworks.SteamServer.OnValidateAuthTicketResponse += OnAuthChange; - } - - private void OnAuthChange(Steamworks.SteamId steamId, Steamworks.SteamId ownerId, Steamworks.AuthResponse status) + private void OnSteamAuthChange(Steamworks.SteamId steamId, Steamworks.SteamId ownerId, Steamworks.AuthResponse status) { if (netServer == null) { return; } @@ -307,8 +302,8 @@ namespace Barotrauma.Networking if (status == Steamworks.AuthResponse.OK) { return; } if (connectedClients.Find(c - => c.AccountInfo.AccountId.TryUnwrap(out var id) && id.Value == steamId) - is LidgrenConnection connection) + => c.Connection.AccountInfo.AccountId.TryUnwrap(out var id) && id.Value == steamId) + is { Connection: LidgrenConnection connection }) { Disconnect(connection, PeerDisconnectPacket.SteamAuthError(status)); } @@ -341,9 +336,15 @@ namespace Barotrauma.Networking { if (netServer == null) { return; } - if (!connectedClients.Contains(conn)) + if (conn is not LidgrenConnection lidgrenConnection) { - DebugConsole.ThrowError($"Tried to send message to unauthenticated connection: {conn.Endpoint.StringRepresentation}"); + DebugConsole.ThrowError($"Tried to send message to connection of incorrect type: expected {nameof(LidgrenConnection)}, got {conn.GetType().Name}"); + return; + } + + if (!connectedClients.Any(cc => cc.Connection == lidgrenConnection)) + { + DebugConsole.ThrowError($"Tried to send message to unauthenticated connection: {lidgrenConnection.Endpoint.StringRepresentation}"); return; } @@ -367,7 +368,7 @@ namespace Barotrauma.Networking { Buffer = bufAux }; - SendMsgInternal(conn, headers, body); + SendMsgInternal(lidgrenConnection, headers, body); } public override void Disconnect(NetworkConnection conn, PeerDisconnectPacket peerDisconnectPacket) @@ -376,18 +377,21 @@ namespace Barotrauma.Networking if (conn is not LidgrenConnection lidgrenConn) { return; } - if (connectedClients.Contains(lidgrenConn)) + if (connectedClients.FindIndex(cc => cc.Connection == lidgrenConn) is >= 0 and var ccIndex) { lidgrenConn.Status = NetworkConnectionStatus.Disconnected; - connectedClients.Remove(lidgrenConn); + connectedClients.RemoveAt(ccIndex); callbacks.OnDisconnect.Invoke(conn, peerDisconnectPacket); - if (conn.AccountInfo.AccountId.TryUnwrap(out var steamId)) { SteamManager.StopAuthSession(steamId); } + if (conn.AccountInfo.AccountId.TryUnwrap(out var accountId)) + { + authenticators?.Values.ForEach(authenticator => authenticator.EndAuthSession(accountId)); + } } lidgrenConn.NetConnection.Disconnect(peerDisconnectPacket.ToLidgrenStringRepresentation()); } - protected override void SendMsgInternal(NetworkConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body) + protected override void SendMsgInternal(LidgrenConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body) { IWriteMessage msgToSend = new WriteOnlyMessage(); msgToSend.WriteNetSerializableStruct(headers); @@ -402,75 +406,74 @@ namespace Barotrauma.Networking protected override void CheckOwnership(PendingClient pendingClient) { - if (OwnerConnection == null - && pendingClient.Connection is LidgrenConnection l - && IPAddress.IsLoopback(l.NetConnection.RemoteEndPoint.Address) - && ownerKey.IsSome() && pendingClient.OwnerKey == ownerKey) + if (OwnerConnection != null + || pendingClient.Connection is not LidgrenConnection l + || !IPAddress.IsLoopback(l.NetConnection.RemoteEndPoint.Address) + || !ownerKey.IsSome() || pendingClient.OwnerKey != ownerKey) { - ownerKey = Option.None(); - OwnerConnection = pendingClient.Connection; - callbacks.OnOwnerDetermined.Invoke(OwnerConnection); + return; } + + ownerKey = Option.None; + OwnerConnection = pendingClient.Connection; + callbacks.OnOwnerDetermined.Invoke(OwnerConnection); } - protected override void ProcessAuthTicket(ClientSteamTicketAndVersionPacket packet, PendingClient pendingClient) + private enum AuthResult { - if (pendingClient.AccountInfo.AccountId.IsNone()) + Success, + Failure + } + + protected override void ProcessAuthTicket(ClientAuthTicketAndVersionPacket packet, PendingClient pendingClient) + { + if (pendingClient.AccountInfo.AccountId.IsSome()) + { + if (pendingClient.AccountInfo.AccountId != packet.AccountId) + { + RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + } + return; + } + + void acceptClient(AccountInfo accountInfo) + { + pendingClient.Connection.SetAccountInfo(accountInfo); + pendingClient.Name = packet.Name; + pendingClient.OwnerKey = packet.OwnerKey; + pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; + } + + void rejectClient() + { + RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.AuthenticationFailed)); + } + + if (authenticators is null + || !packet.AuthTicket.TryUnwrap(out var authTicket) + || !authenticators.TryGetValue(authTicket.Kind, out var authenticator)) { - bool requireSteamAuth = GameSettings.CurrentConfig.RequireSteamAuthentication; #if DEBUG - requireSteamAuth = false; + DebugConsole.NewMessage($"Debug server accepts unauthenticated connections", Microsoft.Xna.Framework.Color.Yellow); + acceptClient(new AccountInfo(packet.AccountId)); +#else + rejectClient(); #endif - bool hasSteamAuth = packet.SteamAuthTicket.TryUnwrap(out var ticket); - - //steam auth cannot be done (SteamManager not initialized or no ticket given), - //but it's not required either -> let the client join without auth - if ((!SteamManager.IsInitialized || !hasSteamAuth) && !requireSteamAuth) - { - pendingClient.Name = packet.Name; - pendingClient.OwnerKey = packet.OwnerKey; - pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; - } - else - { - if (!packet.SteamId.TryUnwrap(out var id) || id is not SteamId steamId) - { - if (requireSteamAuth) - { - RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.SteamAuthenticationFailed)); - return; - } - } - else - { - Steamworks.BeginAuthResult authSessionStartState = SteamManager.StartAuthSession(ticket, steamId); - if (authSessionStartState != Steamworks.BeginAuthResult.OK) - { - if (requireSteamAuth) - { - RemovePendingClient(pendingClient, PeerDisconnectPacket.SteamAuthError(authSessionStartState)); - } - else - { - packet.SteamId = Option.None(); - pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; - } - } - } - - pendingClient.Connection.SetAccountInfo(new AccountInfo(packet.SteamId.Select(uid => (AccountId)uid))); - pendingClient.Name = packet.Name; - pendingClient.OwnerKey = packet.OwnerKey; - pendingClient.AuthSessionStarted = true; - } + return; } - else + + pendingClient.AuthSessionStarted = true; + TaskPool.Add($"{nameof(LidgrenServerPeer)}.ProcessAuth", authenticator.VerifyTicket(authTicket), t => { - if (pendingClient.AccountInfo.AccountId != packet.SteamId.Select(uid => (AccountId)uid)) + if (!t.TryGetResult(out AccountInfo accountInfo) + || accountInfo.IsNone) { - RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.SteamAuthenticationFailed)); + rejectClient(); + return; } - } + + acceptClient(accountInfo); + }); } private NetSendResult ForwardToLidgren(IWriteMessage msg, NetworkConnection connection, DeliveryMethod deliveryMethod) diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/SteamP2PServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs similarity index 59% rename from Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/SteamP2PServerPeer.cs rename to Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs index b51065601..69dc977a6 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/SteamP2PServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/P2PServerPeer.cs @@ -1,30 +1,20 @@ #nullable enable using System; -using System.Collections.Generic; +using System.Linq; namespace Barotrauma.Networking { - internal sealed class SteamP2PServerPeer : ServerPeer + internal sealed class P2PServerPeer : ServerPeer { private bool started; - private readonly SteamId ownerSteamId; + private readonly P2PEndpoint ownerEndpoint; - private UInt64 ownerKey64 => unchecked((UInt64)ownerKey.Fallback(0)); - - private SteamId ReadSteamId(IReadMessage inc) => new SteamId(inc.ReadUInt64() ^ ownerKey64); - private void WriteSteamId(IWriteMessage msg, SteamId val) => msg.WriteUInt64(val.Value ^ ownerKey64); - - public SteamP2PServerPeer(SteamId steamId, int ownerKey, ServerSettings settings, Callbacks callbacks) : base(callbacks) + public P2PServerPeer(P2PEndpoint ownerEp, int ownerKey, ServerSettings settings, Callbacks callbacks) : base(callbacks, settings) { - serverSettings = settings; + this.ownerKey = Option.Some(ownerKey); - connectedClients = new List(); - pendingClients = new List(); - - this.ownerKey = Option.Some(ownerKey); - - ownerSteamId = steamId; + ownerEndpoint = ownerEp; started = false; } @@ -37,7 +27,7 @@ namespace Barotrauma.Networking PacketHeader = PacketHeader.IsConnectionInitializationStep | PacketHeader.IsServerMessage, Initialization = null }; - SendMsgInternal(ownerSteamId, headers, null); + SendMsgInternal(ownerEndpoint, headers, null); started = true; } @@ -55,7 +45,7 @@ namespace Barotrauma.Networking for (int i = connectedClients.Count - 1; i >= 0; i--) { - Disconnect(connectedClients[i], PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown)); + Disconnect(connectedClients[i].Connection, PeerDisconnectPacket.WithReason(DisconnectReason.ServerShutdown)); } pendingClients.Clear(); @@ -73,7 +63,7 @@ namespace Barotrauma.Networking //backwards for loop so we can remove elements while iterating for (int i = connectedClients.Count - 1; i >= 0; i--) { - SteamP2PConnection conn = (SteamP2PConnection)connectedClients[i]; + var conn = connectedClients[i].Connection; conn.Decay(deltaTime); if (conn.Timeout < 0.0) { @@ -83,7 +73,7 @@ namespace Barotrauma.Networking try { - while (ChildServerRelay.Read(out byte[] incBuf)) + foreach (var incBuf in ChildServerRelay.Read()) { IReadMessage inc = new ReadOnlyMessage(incBuf, false, 0, incBuf.Length, OwnerConnection); @@ -114,51 +104,34 @@ namespace Barotrauma.Networking { if (!started) { return; } - SteamId senderSteamId = ReadSteamId(inc); - SteamId sentOwnerSteamId = ReadSteamId(inc); - - var (deliveryMethod, packetHeader, initialization) = INetSerializableStruct.Read(inc); - - if (packetHeader.IsServerMessage()) + var senderInfo = INetSerializableStruct.Read(inc); + if (!senderInfo.Endpoint.TryUnwrap(out var senderEndpoint)) { - DebugConsole.ThrowError($"Got server message from {senderSteamId}"); return; } - if (senderSteamId != ownerSteamId) //sender is remote, handle disconnects and heartbeats - { - bool connectionMatches(NetworkConnection conn) => - conn is SteamP2PConnection { Endpoint: SteamP2PEndpoint { SteamId: var steamId } } - && steamId == senderSteamId; + var (_, packetHeader, initialization) = INetSerializableStruct.Read(inc); - PendingClient? pendingClient = pendingClients.Find(c => connectionMatches(c.Connection)); - SteamP2PConnection? connectedClient = connectedClients.Find(connectionMatches) as SteamP2PConnection; + if (packetHeader.IsServerMessage()) + { + DebugConsole.ThrowError($"Got server message from {senderEndpoint}"); + return; + } + + if (senderEndpoint != ownerEndpoint) //sender is remote, handle disconnects and heartbeats + { + bool connectionMatches(P2PConnection conn) => + conn.Endpoint == senderEndpoint; + + var pendingClient = pendingClients.Find(c => connectionMatches(c.Connection)); + var connectedClient = connectedClients.Find(c => connectionMatches(c.Connection)); + pendingClient?.Connection.SetAccountInfo(senderInfo.AccountInfo); pendingClient?.Heartbeat(); - connectedClient?.Heartbeat(); + connectedClient?.Connection.Heartbeat(); - if (packetHeader.IsConnectionInitializationStep()) - { - if (!initialization.HasValue) { return; } - ConnectionInitialization initializationStep = initialization.Value; - - if (pendingClient != null) - { - pendingClient.Connection.SetAccountInfo(new AccountInfo(senderSteamId, sentOwnerSteamId)); - ReadConnectionInitializationStep( - pendingClient, - new ReadWriteMessage(inc.Buffer, inc.BitPosition, inc.LengthBits, false), - initializationStep); - } - else if (initializationStep == ConnectionInitialization.ConnectionStarted) - { - pendingClient = new PendingClient(new SteamP2PConnection(senderSteamId)); - pendingClient.Connection.SetAccountInfo(new AccountInfo(senderSteamId, sentOwnerSteamId)); - pendingClients.Add(pendingClient); - } - } - else if (serverSettings.BanList.IsBanned(senderSteamId, out string banReason) || - serverSettings.BanList.IsBanned(sentOwnerSteamId, out banReason)) + if (serverSettings.BanList.IsBanned(senderEndpoint, out string banReason) + || serverSettings.BanList.IsBanned(senderInfo.AccountInfo, out banReason)) { if (pendingClient != null) { @@ -166,7 +139,7 @@ namespace Barotrauma.Networking } else if (connectedClient != null) { - Disconnect(connectedClient, PeerDisconnectPacket.Banned(banReason)); + Disconnect(connectedClient.Connection, PeerDisconnectPacket.Banned(banReason)); } } else if (packetHeader.IsDisconnectMessage()) @@ -177,7 +150,7 @@ namespace Barotrauma.Networking } else if (connectedClient != null) { - Disconnect(connectedClient, PeerDisconnectPacket.WithReason(DisconnectReason.Disconnected)); + Disconnect(connectedClient.Connection, PeerDisconnectPacket.WithReason(DisconnectReason.Disconnected)); } } else if (packetHeader.IsHeartbeatMessage()) @@ -185,16 +158,44 @@ namespace Barotrauma.Networking //message exists solely as a heartbeat, ignore its contents return; } + else if (packetHeader.IsConnectionInitializationStep()) + { + if (!initialization.HasValue) { return; } + ConnectionInitialization initializationStep = initialization.Value; + + if (pendingClient != null) + { + ReadConnectionInitializationStep( + pendingClient, + new ReadWriteMessage(inc.Buffer, inc.BitPosition, inc.LengthBits, false), + initializationStep); + } + else if (initializationStep == ConnectionInitialization.ConnectionStarted) + { + pendingClients.Add(new PendingClient(senderEndpoint.MakeConnectionFromEndpoint())); + } + } else if (connectedClient != null) { - var packet = INetSerializableStruct.Read(inc); - IReadMessage msg = new ReadOnlyMessage(packet.Buffer, packetHeader.IsCompressed(), 0, packet.Length, connectedClient); - callbacks.OnMessageReceived.Invoke(connectedClient, msg); + if (packetHeader.IsDataFragment()) + { + var completeMessageOption = connectedClient.Defragmenter.ProcessIncomingFragment(INetSerializableStruct.Read(inc)); + if (!completeMessageOption.TryUnwrap(out var completeMessage)) { return; } + + IReadMessage msg = new ReadOnlyMessage(completeMessage.ToArray(), false, 0, completeMessage.Length, connectedClient.Connection); + callbacks.OnMessageReceived.Invoke(connectedClient.Connection, msg); + } + else + { + var packet = INetSerializableStruct.Read(inc); + IReadMessage msg = new ReadOnlyMessage(packet.Buffer, packetHeader.IsCompressed(), 0, packet.Length, connectedClient.Connection); + callbacks.OnMessageReceived.Invoke(connectedClient.Connection, msg); + } } } else //sender is owner { - (OwnerConnection as SteamP2PConnection)?.Heartbeat(); + OwnerConnection?.Heartbeat(); if (packetHeader.IsDisconnectMessage()) { @@ -212,14 +213,12 @@ namespace Barotrauma.Networking { if (OwnerConnection is null) { - var packet = INetSerializableStruct.Read(inc); - OwnerConnection = new SteamP2PConnection(ownerSteamId) - { - Language = GameSettings.CurrentConfig.Language - }; - OwnerConnection.SetAccountInfo(new AccountInfo(ownerSteamId, ownerSteamId)); + var packet = INetSerializableStruct.Read(inc); + OwnerConnection = ownerEndpoint.MakeConnectionFromEndpoint(); + OwnerConnection.Language = GameSettings.CurrentConfig.Language; + OwnerConnection.SetAccountInfo(senderInfo.AccountInfo); - callbacks.OnInitializationComplete.Invoke(OwnerConnection, packet.OwnerName); + callbacks.OnInitializationComplete.Invoke(OwnerConnection, packet.Name); callbacks.OnOwnerDetermined.Invoke(OwnerConnection); } @@ -239,27 +238,38 @@ namespace Barotrauma.Networking } } - public override void InitializeSteamServerCallbacks() - { - throw new InvalidOperationException("Called InitializeSteamServerCallbacks on SteamP2PServerPeer!"); - } - public override void Send(IWriteMessage msg, NetworkConnection conn, DeliveryMethod deliveryMethod, bool compressPastThreshold = true) { if (!started) { return; } - if (conn is not SteamP2PConnection steamP2PConn) { return; } + if (conn is not P2PConnection p2pConn) { return; } - if (!connectedClients.Contains(steamP2PConn) && conn != OwnerConnection) + int ccIndex = connectedClients.FindIndex(cc => cc.Connection == p2pConn); + if (ccIndex < 0 && conn != OwnerConnection) { - DebugConsole.ThrowError($"Tried to send message to unauthenticated connection: {steamP2PConn.AccountInfo.AccountId}"); + DebugConsole.ThrowError($"Tried to send message to unauthenticated connection: {p2pConn.AccountInfo.AccountId}"); return; } - - if (!conn.AccountInfo.AccountId.TryUnwrap(out var connAccountId) || connAccountId is not SteamId) { return; } - byte[] bufAux = msg.PrepareForSending(compressPastThreshold, out bool isCompressed, out _); + if (bufAux.Length > MessageFragment.MaxSize && conn != OwnerConnection) + { + var cc = connectedClients[ccIndex]; + var fragments = cc.Fragmenter.FragmentMessage(msg.Buffer.AsSpan()[..msg.LengthBytes]); + foreach (var fragment in fragments) + { + var fragmentHeaders = new PeerPacketHeaders + { + DeliveryMethod = DeliveryMethod.Reliable, + PacketHeader = PacketHeader.IsDataFragment + | PacketHeader.IsServerMessage, + Initialization = null + }; + SendMsgInternal(p2pConn, fragmentHeaders, fragment); + } + return; + } + var headers = new PeerPacketHeaders { DeliveryMethod = deliveryMethod, @@ -271,10 +281,10 @@ namespace Barotrauma.Networking { Buffer = bufAux }; - SendMsgInternal(steamP2PConn, headers, body); + SendMsgInternal(p2pConn, headers, body); } - private void SendDisconnectMessage(SteamId steamId, PeerDisconnectPacket peerDisconnectPacket) + private void SendDisconnectMessage(P2PEndpoint endpoint, PeerDisconnectPacket peerDisconnectPacket) { if (!started) { return; } @@ -285,44 +295,41 @@ namespace Barotrauma.Networking Initialization = null }; - SendMsgInternal(steamId, headers, peerDisconnectPacket); + SendMsgInternal(endpoint, headers, peerDisconnectPacket); } public override void Disconnect(NetworkConnection conn, PeerDisconnectPacket peerDisconnectPacket) { if (!started) { return; } - if (conn is not SteamP2PConnection steamp2pConn) { return; } + if (conn is not P2PConnection p2pConn) { return; } - if (!conn.AccountInfo.AccountId.TryUnwrap(out var connAccountId) || connAccountId is not SteamId connSteamId) { return; } + SendDisconnectMessage(p2pConn.Endpoint, peerDisconnectPacket); - SendDisconnectMessage(connSteamId, peerDisconnectPacket); - - if (connectedClients.Contains(steamp2pConn)) + if (connectedClients.FindIndex(cc => cc.Connection == p2pConn) is >= 0 and var ccIndex) { - steamp2pConn.Status = NetworkConnectionStatus.Disconnected; - connectedClients.Remove(steamp2pConn); + p2pConn.Status = NetworkConnectionStatus.Disconnected; + connectedClients.RemoveAt(ccIndex); callbacks.OnDisconnect.Invoke(conn, peerDisconnectPacket); - Steam.SteamManager.StopAuthSession(connSteamId); } - else if (steamp2pConn == OwnerConnection) + else if (p2pConn == OwnerConnection) { throw new InvalidOperationException("Cannot disconnect owner peer"); } } - protected override void SendMsgInternal(NetworkConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body) + protected override void SendMsgInternal(P2PConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body) { - var connSteamId = conn is SteamP2PConnection { Endpoint: SteamP2PEndpoint { SteamId: var id } } ? id : null; - if (connSteamId is null) { return; } - - SendMsgInternal(connSteamId, headers, body); + SendMsgInternal(conn.Endpoint, headers, body); } - - private void SendMsgInternal(SteamId connSteamId, PeerPacketHeaders headers, INetSerializableStruct? body) + + private void SendMsgInternal(P2PEndpoint connEndpoint, PeerPacketHeaders headers, INetSerializableStruct? body) { IWriteMessage msgToSend = new WriteOnlyMessage(); - WriteSteamId(msgToSend, connSteamId); + msgToSend.WriteNetSerializableStruct(new P2PServerToOwnerHeader + { + EndpointStr = connEndpoint.StringRepresentation + }); msgToSend.WriteNetSerializableStruct(headers); body?.Write(msgToSend); @@ -336,11 +343,13 @@ namespace Barotrauma.Networking ChildServerRelay.Write(bufToSend); } - protected override void ProcessAuthTicket(ClientSteamTicketAndVersionPacket packet, PendingClient pendingClient) + protected override void ProcessAuthTicket(ClientAuthTicketAndVersionPacket packet, PendingClient pendingClient) { + // Do nothing with the auth ticket because that should be handled by the owner peer, + // just assume that authentication succeeded pendingClient.InitializationStep = serverSettings.HasPassword ? ConnectionInitialization.Password : ConnectionInitialization.ContentPackageOrder; pendingClient.Name = packet.Name; pendingClient.AuthSessionStarted = true; } } -} \ No newline at end of file +} diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs index 143ac4dae..1c5d06389 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/Primitives/Peers/Server/ServerPeer.cs @@ -7,28 +7,14 @@ using Barotrauma.Extensions; namespace Barotrauma.Networking { - internal abstract class ServerPeer + internal abstract class ServerPeer : ServerPeer where TConnection : NetworkConnection { - public readonly record struct Callbacks( - Callbacks.MessageCallback OnMessageReceived, - Callbacks.DisconnectCallback OnDisconnect, - Callbacks.InitializationCompleteCallback OnInitializationComplete, - Callbacks.ShutdownCallback OnShutdown, - Callbacks.OwnerDeterminedCallback OnOwnerDetermined) - { - public delegate void MessageCallback(NetworkConnection connection, IReadMessage message); - public delegate void DisconnectCallback(NetworkConnection connection, PeerDisconnectPacket peerDisconnectPacket); - public delegate void InitializationCompleteCallback(NetworkConnection connection, string? clientName); - public delegate void ShutdownCallback(); - public delegate void OwnerDeterminedCallback(NetworkConnection connection); - } - - protected readonly Callbacks callbacks; private readonly ImmutableArray contentPackages; - - protected ServerPeer(Callbacks callbacks) + protected ServerPeer(Callbacks callbacks, ServerSettings serverSettings) : base(callbacks) { - this.callbacks = callbacks; + this.serverSettings = serverSettings; + this.connectedClients = new List(); + this.pendingClients = new List(); List contentPackageList = new List(); foreach (var cp in ContentPackageManager.EnabledPackages.All) @@ -45,19 +31,13 @@ namespace Barotrauma.Networking contentPackageList.Add(cp); } contentPackages = contentPackageList.ToImmutableArray(); - } - - public abstract void InitializeSteamServerCallbacks(); - - public abstract void Start(); - public abstract void Close(); - public abstract void Update(float deltaTime); + } protected sealed class PendingClient { public string? Name; public Option OwnerKey; - public readonly NetworkConnection Connection; + public readonly TConnection Connection; public ConnectionInitialization InitializationStep; public double UpdateTime; public double TimeOut; @@ -67,11 +47,11 @@ namespace Barotrauma.Networking public AccountInfo AccountInfo => Connection.AccountInfo; - public PendingClient(NetworkConnection conn) + public PendingClient(TConnection conn) { - OwnerKey = Option.None(); + OwnerKey = Option.None; Connection = conn; - InitializationStep = ConnectionInitialization.SteamTicketAndVersion; + InitializationStep = ConnectionInitialization.AuthInfoAndVersion; Retries = 0; PasswordSalt = null; UpdateTime = Timing.TotalTime + Timing.Step * 3.0; @@ -85,11 +65,26 @@ namespace Barotrauma.Networking } } - protected List connectedClients = null!; - protected List pendingClients = null!; - protected ServerSettings serverSettings = null!; + protected sealed class ConnectedClient + { + public readonly TConnection Connection; + public readonly MessageFragmenter Fragmenter; + public readonly MessageDefragmenter Defragmenter; + + public ConnectedClient(TConnection connection) + { + Connection = connection; + Fragmenter = new(); + Defragmenter = new(); + } + } + + protected readonly List connectedClients; + protected readonly List pendingClients; + protected readonly ServerSettings serverSettings; + + protected TConnection? OwnerConnection; protected Option ownerKey = Option.None; - protected NetworkConnection? OwnerConnection; protected void ReadConnectionInitializationStep(PendingClient pendingClient, IReadMessage inc, ConnectionInitialization initializationStep) { @@ -101,8 +96,8 @@ namespace Barotrauma.Networking switch (initializationStep) { - case ConnectionInitialization.SteamTicketAndVersion: - var authPacket = INetSerializableStruct.Read(inc); + case ConnectionInitialization.AuthInfoAndVersion: + var authPacket = INetSerializableStruct.Read(inc); if (!Client.IsValidName(authPacket.Name, serverSettings)) { @@ -117,8 +112,8 @@ namespace Barotrauma.Networking { RemovePendingClient(pendingClient, PeerDisconnectPacket.InvalidVersion()); - GameServer.Log($"{authPacket.Name} ({authPacket.SteamId}) couldn't join the server (incompatible game version)", ServerLog.MessageType.Error); - DebugConsole.NewMessage($"{authPacket.Name} ({authPacket.SteamId}) couldn't join the server (incompatible game version)", Microsoft.Xna.Framework.Color.Red); + GameServer.Log($"{authPacket.Name} ({authPacket.AccountId}) couldn't join the server (incompatible game version)", ServerLog.MessageType.Error); + DebugConsole.NewMessage($"{authPacket.Name} ({authPacket.AccountId}) couldn't join the server (incompatible game version)", Microsoft.Xna.Framework.Color.Red); return; } @@ -128,7 +123,7 @@ namespace Barotrauma.Networking if (nameTaken != null) { RemovePendingClient(pendingClient, PeerDisconnectPacket.WithReason(DisconnectReason.NameTaken)); - GameServer.Log($"{authPacket.Name} ({authPacket.SteamId}) couldn't join the server (name too similar to the name of the client \"" + nameTaken.Name + "\").", ServerLog.MessageType.Error); + GameServer.Log($"{authPacket.Name} ({authPacket.AccountId}) couldn't join the server (name too similar to the name of the client \"" + nameTaken.Name + "\").", ServerLog.MessageType.Error); return; } @@ -172,7 +167,7 @@ namespace Barotrauma.Networking } } - protected abstract void ProcessAuthTicket(ClientSteamTicketAndVersionPacket packet, PendingClient pendingClient); + protected abstract void ProcessAuthTicket(ClientAuthTicketAndVersionPacket packet, PendingClient pendingClient); protected void BanPendingClient(PendingClient pendingClient, string banReason, TimeSpan? duration) { @@ -214,7 +209,7 @@ namespace Barotrauma.Networking return isBanned; } - protected abstract void SendMsgInternal(NetworkConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body); + protected abstract void SendMsgInternal(TConnection conn, PeerPacketHeaders headers, INetSerializableStruct? body); protected void UpdatePendingClient(PendingClient pendingClient) { @@ -231,8 +226,8 @@ namespace Barotrauma.Networking if (pendingClient.InitializationStep == ConnectionInitialization.Success) { - NetworkConnection newConnection = pendingClient.Connection; - connectedClients.Add(newConnection); + TConnection newConnection = pendingClient.Connection; + connectedClients.Add(new ConnectedClient(newConnection)); pendingClients.Remove(pendingClient); callbacks.OnInitializationComplete.Invoke(newConnection, pendingClient.Name); @@ -305,14 +300,38 @@ namespace Barotrauma.Networking pendingClients.Remove(pendingClient); - if (pendingClient.AuthSessionStarted && pendingClient.AccountInfo.AccountId.TryUnwrap(out var steamId)) - { - Steam.SteamManager.StopAuthSession(steamId); - pendingClient.Connection.SetAccountInfo(AccountInfo.None); - pendingClient.AuthSessionStarted = false; - } + pendingClient.Connection.SetAccountInfo(AccountInfo.None); + pendingClient.AuthSessionStarted = false; } } + } + + internal abstract class ServerPeer + { + public readonly record struct Callbacks( + Callbacks.MessageCallback OnMessageReceived, + Callbacks.DisconnectCallback OnDisconnect, + Callbacks.InitializationCompleteCallback OnInitializationComplete, + Callbacks.ShutdownCallback OnShutdown, + Callbacks.OwnerDeterminedCallback OnOwnerDetermined) + { + public delegate void MessageCallback(NetworkConnection connection, IReadMessage message); + public delegate void DisconnectCallback(NetworkConnection connection, PeerDisconnectPacket peerDisconnectPacket); + public delegate void InitializationCompleteCallback(NetworkConnection connection, string? clientName); + public delegate void ShutdownCallback(); + public delegate void OwnerDeterminedCallback(NetworkConnection connection); + } + + protected readonly Callbacks callbacks; + + protected ServerPeer(Callbacks callbacks) + { + this.callbacks = callbacks; + } + + public abstract void Start(); + public abstract void Close(); + public abstract void Update(float deltaTime); public abstract void Send(IWriteMessage msg, NetworkConnection conn, DeliveryMethod deliveryMethod, bool compressPastThreshold = true); public abstract void Disconnect(NetworkConnection conn, PeerDisconnectPacket peerDisconnectPacket); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs index 7f29c7662..b75d7efc8 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Networking/ServerSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; +using Barotrauma.Steam; namespace Barotrauma.Networking { @@ -281,9 +282,11 @@ namespace Barotrauma.Networking doc.Root.SetAttributeValue("name", ServerName); doc.Root.SetAttributeValue("port", Port); -#if USE_STEAM - doc.Root.SetAttributeValue("queryport", QueryPort); -#endif + + if (QueryPort != 0) + { + doc.Root.SetAttributeValue("queryport", QueryPort); + } doc.Root.SetAttributeValue("password", password ?? ""); doc.Root.SetAttributeValue("enableupnp", EnableUPnP); diff --git a/Barotrauma/BarotraumaServer/ServerSource/Program.cs b/Barotrauma/BarotraumaServer/ServerSource/Program.cs index e1d371659..5fb8fe15c 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Program.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Program.cs @@ -6,6 +6,8 @@ using System.Diagnostics; using Barotrauma.IO; using System.Linq; using System.Text; +using System.Threading; +using Barotrauma.Debugging; using Barotrauma.Networking; #if LINUX using System.Runtime.InteropServices; @@ -48,8 +50,9 @@ namespace Barotrauma [STAThread] static void Main(string[] args) { -#if !DEBUG AppDomain currentDomain = AppDomain.CurrentDomain; + currentDomain.ProcessExit += OnProcessExit; +#if !DEBUG currentDomain.UnhandledException += new UnhandledExceptionEventHandler(CrashHandler); #endif TryStartChildServerRelay(args); @@ -74,11 +77,37 @@ namespace Barotrauma string executableDir = Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location); Directory.SetCurrentDirectory(executableDir); + DebugConsoleCore.Init( + newMessage: (s, c) => DebugConsole.NewMessage(s, c), + log: DebugConsole.Log); Game = new GameMain(args); Game.Run(); + ShutDown(); + } + + private static bool hasShutDown = false; + private static void ShutDown() + { + if (hasShutDown) { return; } + hasShutDown = true; + if (GameAnalyticsManager.SendUserStatistics) { GameAnalyticsManager.ShutDown(); } SteamManager.ShutDown(); + + // Gracefully exit EOS by ticking until the session is closed + EosInterface.Core.CleanupAndQuit(); + while (EosInterface.Core.IsInitialized) + { + EosInterface.Core.Update(); + TaskPool.Update(); + Thread.Sleep(16); + } + } + + private static void OnProcessExit(object sender, EventArgs e) + { + ShutDown(); } static GameMain Game; diff --git a/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs index 039e30109..bfcefab09 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Steam/SteamManager.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using System.Linq; +using System.Collections.Generic; using Barotrauma.Networking; namespace Barotrauma.Steam @@ -31,8 +30,6 @@ namespace Barotrauma.Steam RefreshServerDetails(server); - server.ServerPeer.InitializeSteamServerCallbacks(); - Steamworks.SteamServer.LogOnAnonymous(); return true; @@ -45,75 +42,40 @@ namespace Barotrauma.Steam return false; } - var contentPackages = ContentPackageManager.EnabledPackages.All.Where(cp => cp.HasMultiplayerSyncedContent); - - // These server state variables may be changed at any time. Note that there is no longer a mechanism - // to send the player count. The player count is maintained by Steam and you should use the player - // creation/authentication functions to maintain your player count. - Steamworks.SteamServer.ServerName = server.ServerName; - Steamworks.SteamServer.MaxPlayers = server.ServerSettings.MaxPlayers; - Steamworks.SteamServer.Passworded = server.ServerSettings.HasPassword; Steamworks.SteamServer.MapName = GameMain.NetLobbyScreen?.SelectedSub?.DisplayName?.Value ?? ""; - Steamworks.SteamServer.SetKey("haspassword", server.ServerSettings.HasPassword.ToString()); - Steamworks.SteamServer.SetKey("message", server.ServerSettings.ServerMessageText); - Steamworks.SteamServer.SetKey("version", GameMain.Version.ToString()); - Steamworks.SteamServer.SetKey("playercount", server.ConnectedClients.Count.ToString()); - - //a2s seems to break if too much data is added (seems to be related to MTU?) - //let's restrict the number of packages to 10, clients can use packagecount to tell when the list has been truncated - const int MaxPackagesToList = 10; - int index = 0; - foreach (var contentPackage in contentPackages.Take(MaxPackagesToList)) - { - string ugcIdStr = contentPackage.UgcId.TryUnwrap(out var ugcId) ? ugcId.StringRepresentation : string.Empty; - Steamworks.SteamServer.SetKey( - $"contentpackage{index}", - contentPackage.Name + "," + contentPackage.Hash.StringRepresentation + "," + ugcIdStr); - index++; - } - Steamworks.SteamServer.SetKey("packagecount", contentPackages.Count().ToString()); - Steamworks.SteamServer.SetKey("modeselectionmode", server.ServerSettings.ModeSelectionMode.ToString()); - Steamworks.SteamServer.SetKey("subselectionmode", server.ServerSettings.SubSelectionMode.ToString()); - Steamworks.SteamServer.SetKey("voicechatenabled", server.ServerSettings.VoiceChatEnabled.ToString()); - Steamworks.SteamServer.SetKey("allowspectating", server.ServerSettings.AllowSpectating.ToString()); - Steamworks.SteamServer.SetKey("allowrespawn", server.ServerSettings.AllowRespawn.ToString()); - Steamworks.SteamServer.SetKey("traitors", server.ServerSettings.TraitorProbability.ToString(CultureInfo.InvariantCulture)); - Steamworks.SteamServer.SetKey("friendlyfireenabled", server.ServerSettings.AllowFriendlyFire.ToString()); - Steamworks.SteamServer.SetKey("karmaenabled", server.ServerSettings.KarmaEnabled.ToString()); - Steamworks.SteamServer.SetKey("gamestarted", server.GameStarted.ToString()); - Steamworks.SteamServer.SetKey("gamemode", server.ServerSettings.GameModeIdentifier.Value); - Steamworks.SteamServer.SetKey("playstyle", server.ServerSettings.PlayStyle.ToString()); - Steamworks.SteamServer.SetKey("language", server.ServerSettings.Language.ToString()); - if (GameMain.NetLobbyScreen?.SelectedSub != null) - { - Steamworks.SteamServer.SetKey("submarine", GameMain.NetLobbyScreen.SelectedSub.Name); - } + server.ServerSettings.UpdateServerListInfo(SetServerListInfo); Steamworks.SteamServer.DedicatedServer = true; return true; } - public static Steamworks.BeginAuthResult StartAuthSession(byte[] authTicketData, SteamId clientSteamID) + private static void SetServerListInfo(Identifier key, object value) { - if (!IsInitialized || !Steamworks.SteamServer.IsValid) return Steamworks.BeginAuthResult.ServerNotConnectedToSteam; - - DebugConsole.Log("SteamManager authenticating Steam client " + clientSteamID); - Steamworks.BeginAuthResult startResult = Steamworks.SteamServer.BeginAuthSession(authTicketData, clientSteamID.Value); - if (startResult != Steamworks.BeginAuthResult.OK) + switch (value) { - DebugConsole.Log("Authentication failed: failed to start auth session (" + startResult.ToString() + ")"); + case string stringValue when key == "ServerName": + Steamworks.SteamServer.ServerName = stringValue; + return; + case int maxPlayers when key == "MaxPlayers": + Steamworks.SteamServer.MaxPlayers = maxPlayers; + return; + case bool hasPassword when key == "HasPassword": + Steamworks.SteamServer.Passworded = hasPassword; + return; + case IEnumerable contentPackages: + int index = 0; + foreach (var contentPackage in contentPackages) + { + Steamworks.SteamServer.SetKey( + $"contentpackage{index}", + new ServerListContentPackageInfo(contentPackage).ToString()); + index++; + } + return; } - - return startResult; - } - - public static void StopAuthSession(SteamId clientSteamId) - { - if (!IsInitialized || !Steamworks.SteamServer.IsValid) return; - - DebugConsole.Log("SteamManager ending auth session with Steam client " + clientSteamId); - Steamworks.SteamServer.EndSession(clientSteamId.Value); + + Steamworks.SteamServer.SetKey(key.Value.ToLowerInvariant(), value.ToString()); } public static bool CloseServer() diff --git a/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs b/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs index 498108b29..004e422df 100644 --- a/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs +++ b/Barotrauma/BarotraumaServer/ServerSource/Traitors/TraitorManager.cs @@ -458,10 +458,10 @@ namespace Barotrauma if (activeEvent.TraitorEvent.CurrentState == TraitorEvent.State.Completed) { - SteamAchievementManager.OnTraitorWin(activeEvent.TraitorEvent.Traitor?.Character); + AchievementManager.OnTraitorWin(activeEvent.TraitorEvent.Traitor?.Character); foreach (var secondaryTraitor in activeEvent.TraitorEvent.SecondaryTraitors) { - SteamAchievementManager.OnTraitorWin(secondaryTraitor?.Character); + AchievementManager.OnTraitorWin(secondaryTraitor?.Character); } } } diff --git a/Barotrauma/BarotraumaServer/WindowsServer.csproj b/Barotrauma/BarotraumaServer/WindowsServer.csproj index 9573174e1..d7aaef3d6 100644 --- a/Barotrauma/BarotraumaServer/WindowsServer.csproj +++ b/Barotrauma/BarotraumaServer/WindowsServer.csproj @@ -6,7 +6,7 @@ Barotrauma FakeFish, Undertow Games Barotrauma Dedicated Server - 1.2.8.0 + 1.3.0.1 Copyright © FakeFish 2018-2023 AnyCPU;x64 DedicatedServer @@ -17,13 +17,13 @@ - DEBUG;TRACE;SERVER;WINDOWS;USE_STEAM + DEBUG;TRACE;SERVER;WINDOWS x64 ..\bin\$(Configuration)Windows\ - TRACE;DEBUG;SERVER;WINDOWS;X64;USE_STEAM + TRACE;DEBUG;SERVER;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -31,20 +31,20 @@ - TRACE;SERVER;WINDOWS;USE_STEAM + TRACE;SERVER;WINDOWS x64 ..\bin\$(Configuration)Windows\ - TRACE;SERVER;WINDOWS;USE_STEAM + TRACE;SERVER;WINDOWS x64 ..\bin\$(Configuration)Windows\ true - TRACE;SERVER;WINDOWS;X64;USE_STEAM + TRACE;SERVER;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -52,7 +52,7 @@ - TRACE;SERVER;WINDOWS;X64;USE_STEAM + TRACE;SERVER;WINDOWS;X64 x64 ..\bin\$(Configuration)Windows\ full @@ -62,7 +62,6 @@ - @@ -87,6 +86,11 @@ + + + + + @@ -152,4 +156,10 @@ + + win-x64 + Win64 + + + diff --git a/Barotrauma/BarotraumaShared/DeployEosPrivate.props b/Barotrauma/BarotraumaShared/DeployEosPrivate.props new file mode 100644 index 000000000..a4a1e85e8 --- /dev/null +++ b/Barotrauma/BarotraumaShared/DeployEosPrivate.props @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/DeployGameAnalytics.props b/Barotrauma/BarotraumaShared/DeployGameAnalytics.props index e4818697e..9a7d707e1 100644 --- a/Barotrauma/BarotraumaShared/DeployGameAnalytics.props +++ b/Barotrauma/BarotraumaShared/DeployGameAnalytics.props @@ -9,10 +9,10 @@ false - + - + \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/SteamAchievementManager.cs b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs similarity index 74% rename from Barotrauma/BarotraumaShared/SharedSource/SteamAchievementManager.cs rename to Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs index 37064ac10..2cf5ccbb5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/SteamAchievementManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/AchievementManager.cs @@ -5,11 +5,15 @@ using FarseerPhysics; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; namespace Barotrauma { - static class SteamAchievementManager + [NetworkSerialize] + internal readonly record struct NetIncrementedStat(AchievementStat Stat, float Amount) : INetSerializableStruct; + + static class AchievementManager { private const float UpdateInterval = 1.0f; @@ -22,7 +26,7 @@ namespace Barotrauma /// /// Keeps track of things that have happened during the round /// - class RoundData + private sealed class RoundData { public readonly List Reactors = new List(); @@ -61,12 +65,15 @@ namespace Barotrauma updateTimer -= deltaTime; if (updateTimer > 0.0f) { return; } updateTimer = UpdateInterval; - + if (Level.Loaded != null && roundData != null && Screen.Selected == GameMain.GameScreen) { if (GameMain.GameSession.EventManager.CurrentIntensity > 0.99f) { - UnlockAchievement("maxintensity".ToIdentifier(), true, c => c != null && !c.IsDead && !c.IsUnconscious); + UnlockAchievement( + identifier: "maxintensity".ToIdentifier(), + unlockClients: true, + conditions: static c => c is { IsDead: false, IsUnconscious: false }); } foreach (Character c in Character.CharacterList) @@ -273,12 +280,12 @@ namespace Barotrauma causeOfDeath.Killer != null && causeOfDeath.Killer == Character.Controlled) { - IncrementStat(causeOfDeath.Killer, (character.IsHuman ? "humanskilled" : "monsterskilled").ToIdentifier(), 1); + IncrementStat(causeOfDeath.Killer, character.IsHuman ? AchievementStat.HumansKilled : AchievementStat.MonstersKilled , 1); } #elif SERVER if (character != causeOfDeath.Killer && causeOfDeath.Killer != null) { - IncrementStat(causeOfDeath.Killer, (character.IsHuman ? "humanskilled" : "monsterskilled").ToIdentifier(), 1); + IncrementStat(causeOfDeath.Killer, character.IsHuman ? AchievementStat.HumansKilled : AchievementStat.MonstersKilled , 1); } #endif @@ -375,14 +382,14 @@ namespace Barotrauma !myCharacter.IsDead && (myCharacter.Submarine == gameSession.Submarine || (Level.Loaded?.EndOutpost != null && myCharacter.Submarine == Level.Loaded.EndOutpost))) { - IncrementStat("kmstraveled".ToIdentifier(), levelLengthKilometers); + IncrementStat(AchievementStat.KMsTraveled, levelLengthKilometers); } #endif } else { //in sp making it to the end is enough - IncrementStat("kmstraveled".ToIdentifier(), levelLengthKilometers); + IncrementStat(AchievementStat.KMsTraveled, levelLengthKilometers); } } @@ -497,31 +504,19 @@ namespace Barotrauma #endif } - private static void IncrementStat(Character recipient, Identifier identifier, int amount) + private static void IncrementStat(Character recipient, AchievementStat stat, int amount) { if (CheatsEnabled || recipient == null) { return; } #if CLIENT if (recipient == Character.Controlled) { - SteamManager.IncrementStat(identifier, amount); + IncrementStat(stat, amount); } #elif SERVER - GameMain.Server?.IncrementStat(recipient, identifier, amount); + GameMain.Server?.IncrementStat(recipient, stat, amount); #endif } - public static void IncrementStat(Identifier identifier, int amount) - { - if (CheatsEnabled) { return; } - SteamManager.IncrementStat(identifier, amount); - } - - public static void IncrementStat(Identifier identifier, float amount) - { - if (CheatsEnabled) { return; } - SteamManager.IncrementStat(identifier, amount); - } - public static void UnlockAchievement(Identifier identifier, bool unlockClients = false, Func conditions = null) { if (CheatsEnabled) { return; } @@ -539,15 +534,150 @@ namespace Barotrauma } } #endif - //already unlocked, no need to do anything - if (unlockedAchievements.Contains(identifier)) { return; } - unlockedAchievements.Add(identifier); #if CLIENT if (conditions != null && !conditions(Character.Controlled)) { return; } #endif - SteamManager.UnlockAchievement(identifier); + UnlockAchievementsOnPlatforms(identifier); + } + + private static void UnlockAchievementsOnPlatforms(Identifier identifier) + { + if (unlockedAchievements.Contains(identifier)) { return; } + + if (SteamManager.IsInitialized) + { + if (SteamManager.UnlockAchievement(identifier)) + { + unlockedAchievements.Add(identifier); + } + } + + if (EosInterface.Core.IsInitialized) + { + TaskPool.Add("Eos.UnlockAchievementsOnPlatforms", EosInterface.Achievements.UnlockAchievements(identifier), t => + { + if (!t.TryGetResult(out Result result)) { return; } + if (result.IsSuccess) { unlockedAchievements.Add(identifier); } + }); + } + } + + public static void IncrementStat(AchievementStat stat, float amount) + { + if (CheatsEnabled) { return; } + + IncrementStatOnPlatforms(stat, amount); + } + + private static void IncrementStatOnPlatforms(AchievementStat stat, float amount) + { + if (SteamManager.IsInitialized) + { + SteamManager.IncrementStats(stat.ToSteam(amount)); + } + + if (EosInterface.Core.IsInitialized) + { + TaskPool.Add("Eos.IncrementStat", EosInterface.Achievements.IngestStats(stat.ToEos(amount)), TaskPool.IgnoredCallback); + } + } + + public static void SyncBetweenPlatforms() + { + if (!SteamManager.IsInitialized || !EosInterface.Core.IsInitialized) { return; } + + var steamStats = SteamManager.GetAllStats(); + + TaskPool.AddWithResult("Eos.SyncBetweenPlatforms.QueryStats", EosInterface.Achievements.QueryStats(AchievementStatExtension.EosStats), result => + { + result.Match( + success: stats => SyncStats(stats, steamStats), + failure: static error => DebugConsole.ThrowError($"Failed to query stats from EOS: {error}")); + }); + + static void SyncStats(ImmutableDictionary eosStats, + ImmutableDictionary steamStats) + { + var steamStatsConverted = steamStats.Select(static s => s.Key.ToEos(s.Value)).ToImmutableDictionary(static s => s.Stat, static s => s.Value); + var eosStatsConverted = eosStats.Select(static s => s.Key.ToEos(s.Value)).ToImmutableDictionary(static s => s.Stat, static s => s.Value); + + static int GetStatValue(AchievementStat stat, ImmutableDictionary stats) => stats.TryGetValue(stat, out int value) ? value : 0; + + var highestStats = AchievementStatExtension.EosStats.ToDictionary( + static key => key, + value => + Math.Max( + GetStatValue(value, steamStatsConverted), + GetStatValue(value, eosStatsConverted))); + + List<(AchievementStat Stat, int Value)> eosStatsToIngest = new(), + steamStatsToIncrement = new(); + + foreach (var (stat, value) in highestStats) + { + int steamDiff = value - GetStatValue(stat, steamStatsConverted), + eosDiff = value - GetStatValue(stat, eosStatsConverted); + + if (steamDiff > 0) { steamStatsToIncrement.Add((stat, steamDiff)); } + if (eosDiff > 0) { eosStatsToIngest.Add((stat, eosDiff)); } + } + + if (steamStatsToIncrement.Any()) + { + SteamManager.IncrementStats(steamStatsToIncrement.Select(static s => s.Stat.ToSteam(s.Value)).ToArray()); + SteamManager.StoreStats(); + } + + if (eosStatsToIngest.Any()) + { + TaskPool.Add("Eos.SyncBetweenPlatforms.IngestStats", EosInterface.Achievements.IngestStats(eosStatsToIngest.ToArray()), TaskPool.IgnoredCallback); + } + } + + if (!SteamManager.TryGetUnlockedAchievements(out List steamUnlockedAchievements)) + { + DebugConsole.ThrowError("Failed to query unlocked achievements from Steam"); + return; + } + + TaskPool.AddWithResult("Eos.SyncBetweenPlatforms.QueryPlayerAchievements", EosInterface.Achievements.QueryPlayerAchievements(), t => + { + t.Match( + success: eosAchievements => SyncAchievements(eosAchievements, steamUnlockedAchievements), + failure: static error => DebugConsole.ThrowError($"Failed to query achievements from EOS: {error}")); + }); + + static void SyncAchievements( + ImmutableDictionary eosAchievements, + List steamUnlockedAchievements) + { + foreach (var (identifier, progress) in eosAchievements) + { + if (!IsUnlocked(progress)) { continue; } + + if (steamUnlockedAchievements.Any(a => a.Identifier.ToIdentifier() == identifier)) { continue; } + + SteamManager.UnlockAchievement(identifier); + } + + List eosAchievementsToUnlock = new(); + foreach (var achievement in steamUnlockedAchievements) + { + Identifier identifier = achievement.Identifier.ToIdentifier(); + if (eosAchievements.TryGetValue(identifier, out double progress) && IsUnlocked(progress)) { continue; } + + eosAchievementsToUnlock.Add(achievement.Identifier.ToIdentifier()); + } + + if (eosAchievementsToUnlock.Any()) + { + TaskPool.Add("Eos.SyncBetweenPlatforms.UnlockAchievements", EosInterface.Achievements.UnlockAchievements(eosAchievementsToUnlock.ToArray()), TaskPool.IgnoredCallback); + } + + static bool IsUnlocked(double progress) => progress >= 100.0d; + } } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs index 831287a4b..966417d20 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Animation/HumanoidAnimController.cs @@ -1561,7 +1561,7 @@ namespace Barotrauma if (wasCritical && target.Vitality > 0.0f && Timing.TotalTime > lastReviveTime + 10.0f) { character.Info?.ApplySkillGain(Tags.MedicalSkill, SkillSettings.Current.SkillIncreasePerCprRevive); - SteamAchievementManager.OnCharacterRevived(target, character); + AchievementManager.OnCharacterRevived(target, character); lastReviveTime = (float)Timing.TotalTime; #if SERVER GameMain.Server?.KarmaManager?.OnCharacterHealthChanged(target, character, damage: Math.Min(prevVitality - target.Vitality, 0.0f), stun: 0.0f); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs index 21ce51013..88aa76a5f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Character.cs @@ -1270,14 +1270,11 @@ namespace Barotrauma return newCharacter; } - private Character(Submarine submarine, ushort id): base(submarine, id) + protected Character(CharacterPrefab prefab, Vector2 position, string seed, CharacterInfo characterInfo = null, ushort id = Entity.NullEntityID, bool isRemotePlayer = false, RagdollParams ragdollParams = null, bool spawnInitialItems = true) + : base(null, id) { wallet = new Wallet(Option.Some(this)); - } - protected Character(CharacterPrefab prefab, Vector2 position, string seed, CharacterInfo characterInfo = null, ushort id = Entity.NullEntityID, bool isRemotePlayer = false, RagdollParams ragdollParams = null, bool spawnInitialItems = true) - : this(null, id) - { this.Seed = seed; this.Prefab = prefab; MTRandom random = new MTRandom(ToolBox.StringToInt(seed)); @@ -2478,7 +2475,7 @@ namespace Barotrauma return false; } - public Item GetEquippedItem(Identifier? tagOrIdentifier = null, InvSlotType? slotType = null) + public Item GetEquippedItem(Identifier tagOrIdentifier = default, InvSlotType? slotType = null) { if (Inventory == null) { return null; } for (int i = 0; i < Inventory.Capacity; i++) @@ -2493,7 +2490,7 @@ namespace Barotrauma } var item = Inventory.GetItemAt(i); if (item == null) { continue; } - if (tagOrIdentifier == null || tagOrIdentifier.Value.IsEmpty || item.Prefab.Identifier == tagOrIdentifier || item.HasTag(tagOrIdentifier.Value)) + if (tagOrIdentifier.IsEmpty || item.Prefab.Identifier == tagOrIdentifier || item.HasTag(tagOrIdentifier)) { return item; } @@ -4658,7 +4655,7 @@ namespace Barotrauma if (GameMain.GameSession != null && Screen.Selected == GameMain.GameScreen) { - SteamAchievementManager.OnCharacterKilled(this, CauseOfDeath); + AchievementManager.OnCharacterKilled(this, CauseOfDeath); } KillProjSpecific(causeOfDeath, causeOfDeathAffliction, log); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs index 2b8466406..d733b8f92 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/CharacterInfo.cs @@ -1459,7 +1459,7 @@ namespace Barotrauma charElement.Add(new XAttribute("missionscompletedsincedeath", MissionsCompletedSinceDeath)); - if (MinReputationToHire.factionId != default) + if (!MinReputationToHire.factionId.IsEmpty) { charElement.Add( new XAttribute("factionId", MinReputationToHire.factionId), diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs index 7e813b74a..c5fa49f23 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Health/CharacterHealth.cs @@ -570,7 +570,7 @@ namespace Barotrauma matchingAfflictions.RemoveAt(i); if (i == 0) { i = matchingAfflictions.Count; } if (i > 0) { reduceAmount += surplus / i; } - SteamAchievementManager.OnAfflictionRemoved(matchingAffliction, Character); + AchievementManager.OnAfflictionRemoved(matchingAffliction, Character); } else { @@ -777,7 +777,7 @@ namespace Barotrauma Math.Min(newAffliction.Prefab.MaxStrength, newAffliction.Strength * (100.0f / MaxVitality) * (1f - GetResistance(newAffliction.Prefab))), newAffliction.Source); afflictions.Add(copyAffliction, limbHealth); - SteamAchievementManager.OnAfflictionReceived(copyAffliction, Character); + AchievementManager.OnAfflictionReceived(copyAffliction, Character); MedicalClinic.OnAfflictionCountChanged(Character); Character.HealthUpdateInterval = 0.0f; @@ -818,7 +818,7 @@ namespace Barotrauma var affliction = kvp.Key; if (affliction.Strength <= 0.0f) { - SteamAchievementManager.OnAfflictionRemoved(affliction, Character); + AchievementManager.OnAfflictionRemoved(affliction, Character); if (!irremovableAfflictions.Contains(affliction)) { afflictionsToRemove.Add(affliction); } continue; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasItem.cs index 7d75c9f7d..9fbcfa9ab 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHasItem.cs @@ -18,7 +18,7 @@ namespace Barotrauma.Abilities { if (tags.None()) { - return character.GetEquippedItem(null) != null; + return character.GetEquippedItem(Identifier.Empty) != null; } if (requireAll) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHoldingItem.cs b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHoldingItem.cs index 00eb57b23..42c78a4a5 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHoldingItem.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Characters/Talents/Abilities/AbilityConditionals/AbilityConditionDataless/AbilityConditionHoldingItem.cs @@ -16,7 +16,7 @@ internal sealed class AbilityConditionHoldingItem : AbilityConditionDataless { if (tags.Count is 0) { - return HasItemInHand(character, null); + return HasItemInHand(character, Identifier.Empty); } foreach (Identifier tag in tags) @@ -26,7 +26,7 @@ internal sealed class AbilityConditionHoldingItem : AbilityConditionDataless return false; - static bool HasItemInHand(Character character, Identifier? tagOrIdentifier) => + static bool HasItemInHand(Character character, Identifier tagOrIdentifier) => character.GetEquippedItem(tagOrIdentifier, InvSlotType.RightHand) is not null || character.GetEquippedItem(tagOrIdentifier, InvSlotType.LeftHand) is not null; diff --git a/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxCursor.cs b/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxCursor.cs index 1340152a0..df7ea9a06 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxCursor.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/CircuitBox/CircuitBoxCursor.cs @@ -54,7 +54,7 @@ namespace Barotrauma public static Color GenerateColor(string name) { Random random = new Random(ToolBox.StringToInt(name)); - return ToolBox.HSVToRGB(random.NextSingle() * 360f, 1f, 1f); + return ToolBoxCore.HSVToRGB(random.NextSingle() * 360f, 1f, 1f); } private const float UpdateTimeout = 5f; diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/SubmarineFile.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/SubmarineFile.cs index f3a777cb0..f24a0fc4b 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/SubmarineFile.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentFile/SubmarineFile.cs @@ -9,7 +9,7 @@ namespace Barotrauma { using var md5 = MD5.Create(); #warning TODO: this doesn't account for collisions, this should probably be using the PrefabCollection class like everything else - UintIdentifier = ToolBox.StringToUInt32Hash(Barotrauma.IO.Path.GetFileNameWithoutExtension(path.Value), md5); + UintIdentifier = ToolBoxCore.StringToUInt32Hash(Barotrauma.IO.Path.GetFileNameWithoutExtension(path.Value), md5); } public readonly UInt32 UintIdentifier; diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs index c932b4470..a812a773f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackage/ContentPackage.cs @@ -72,9 +72,9 @@ namespace Barotrauma if (ugcId is not SteamWorkshopId steamWorkshopId) { return true; } if (!InstallTime.TryUnwrap(out var installTime)) { return true; } - Steamworks.Ugc.Item? item = await SteamManager.Workshop.GetItem(steamWorkshopId.Value); - if (item is null) { return true; } - return item.Value.LatestUpdateTime <= installTime.ToUtcValue(); + Option itemOption = await SteamManager.Workshop.GetItem(steamWorkshopId.Value); + if (!itemOption.TryUnwrap(out var item)) { return true; } + return item.LatestUpdateTime <= installTime.ToUtcValue(); } public int Index => ContentPackageManager.EnabledPackages.IndexOf(this); diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs index dc6a465be..b853d9705 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentPackageManager.cs @@ -306,7 +306,7 @@ namespace Barotrauma { onLoadFail?.Invoke( fileListPath, - result.TryUnwrapFailure(out var exception) ? exception : throw new Exception("unreachable")); + result.TryUnwrapFailure(out var exception) ? exception : throw new UnreachableCodeException()); continue; } @@ -497,7 +497,7 @@ namespace Barotrauma List enabledRegularPackages = new List(); #if CLIENT - TaskPool.Add("EnqueueWorkshopUpdates", EnqueueWorkshopUpdates(), t => { }); + TaskPool.AddWithResult("EnqueueWorkshopUpdates", EnqueueWorkshopUpdates(), t => { }); #else #warning TODO: implement Workshop updates for servers at some point #endif diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs index 26afb242f..7559fe604 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/ContentXElement.cs @@ -20,6 +20,7 @@ namespace Barotrauma Element = element; } + [return: NotNullIfNotNull("cxe")] public static implicit operator XElement?(ContentXElement? cxe) => cxe?.Element; //public static implicit operator ContentXElement?(XElement? xe) => xe is null ? null : new ContentXElement(null, xe); diff --git a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs index 0805e53d6..a013fabf8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/DebugConsole.cs @@ -1,8 +1,6 @@ using Barotrauma.Extensions; using Barotrauma.Items.Components; using Barotrauma.Networking; -using Barotrauma.Steam; -using FarseerPhysics; using Microsoft.Xna.Framework; using System; using System.Collections.Concurrent; @@ -12,8 +10,9 @@ using System.ComponentModel; using System.Globalization; using Barotrauma.IO; using System.Linq; -using System.Text; +using System.Threading.Tasks; using Barotrauma.MapCreatures.Behavior; +using System.Text; namespace Barotrauma { @@ -79,9 +78,7 @@ namespace Barotrauma { NewMessage( $"You need to enable cheats using the command \"enablecheats\" before you can use the command \"{Names.First()}\".", Color.Red); -#if USE_STEAM NewMessage("Enabling cheats will disable Steam achievements during this play session.", Color.Red); -#endif return; } @@ -182,7 +179,6 @@ namespace Barotrauma #if DEBUG CheatsEnabled = true; #endif - commands.Add(new Command("help", "", (string[] args) => { if (args.Length == 0) @@ -1815,7 +1811,7 @@ namespace Barotrauma NewMessage((GameSettings.CurrentConfig.VerboseLogging ? "Enabled" : "Disabled") + " verbose logging.", Color.White); }, isCheat: false)); - commands.Add(new Command("listtasks", "listtasks: Lists all asynchronous tasks currently in the task pool.", (string[] args) => { TaskPool.ListTasks(); })); + commands.Add(new Command("listtasks", "listtasks: Lists all asynchronous tasks currently in the task pool.", (string[] args) => { TaskPool.ListTasks(line => DebugConsole.NewMessage(line)); })); commands.Add(new Command("listcoroutines", "listcoroutines: Lists all coroutines currently running.", (string[] args) => { CoroutineManager.ListCoroutines(); })); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Eos/Session.cs b/Barotrauma/BarotraumaShared/SharedSource/Eos/Session.cs new file mode 100644 index 000000000..d87a7c2e1 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Eos/Session.cs @@ -0,0 +1,117 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Barotrauma.Networking; +using Barotrauma.Steam; + +namespace Barotrauma.Eos; + +static class EosSessionManager +{ + public static Option CurrentOwnedSession; + + public static void LeaveSession() + { + if (!CurrentOwnedSession.TryUnwrap(out var ownedSession)) { return; } + ownedSession.Dispose(); + CurrentOwnedSession = Option.None; + } + + public static void UpdateOwnedSession(Endpoint endpoint, ServerSettings serverSettings) + => UpdateOwnedSession(Option.Some(endpoint), serverSettings); + + public static void UpdateOwnedSession(Option endpoint, ServerSettings serverSettings) + { + if (!EosInterface.Core.IsInitialized) { return; } + + if (!serverSettings.IsPublic) + { + // Sessions can only be public, so if that's not what we want then + // destroy the current one if it exists and do not attempt to create + // or update one + LeaveSession(); + return; + } + + var selfPuids = EosInterface.IdQueries.GetLoggedInPuids(); + if (!CurrentOwnedSession.TryUnwrap(out var ownedSession)) + { + if (!TaskPool.IsTaskRunning("CreateOwnedSession")) + { + TaskPool.Add( + "CreateOwnedSession", + EosInterface.Sessions.CreateSession(selfPuids.Any() ? Option.Some(selfPuids.First()) : Option.None, internalId: "OwnedSession".ToIdentifier(), maxPlayers: serverSettings.MaxPlayers), + t => + { + LeaveSession(); + if (!t.TryGetResult(out Result? result)) { return; } + if (!result.TryUnwrapSuccess(out var newOwnedSession)) + { + if (result.TryUnwrapFailure(out var error) && + error is EosInterface.Sessions.CreateError.SessionAlreadyExists) + { + // If the session already exists then this failure is not a problem + return; + } + DebugConsole.ThrowError($"Failed to create session: {result}"); + return; + } + CurrentOwnedSession = Option.Some(newOwnedSession); + UpdateOwnedSession(endpoint, serverSettings); + }); + } + return; + } + + if (selfPuids.Length > 0) + { + endpoint = Option.Some(new EosP2PEndpoint(selfPuids.First())); + } + ownedSession.HostAddress = endpoint.Select(e1 => e1.StringRepresentation); + if (endpoint.TryUnwrap(out var e2) && e2 is LidgrenEndpoint { Port: var port }) + { + SetAttributeValue("Port".ToIdentifier(), port.ToString()); + } + else if (serverSettings.Port != 0) + { + SetAttributeValue("Port".ToIdentifier(), serverSettings.Port.ToString()); + } + + if (SteamManager.GetSteamId().TryUnwrap(out var steamId)) + { + SetAttributeValue("SteamP2PEndpoint".ToIdentifier(), steamId.StringRepresentation); + } + + serverSettings.UpdateServerListInfo(SetAttributeValue); + TaskPool.Add( + "UpdateOwnedSessionAttributes", + ownedSession.UpdateAttributes(), + t => + { + if (!t.TryGetResult(out Result? result)) { return; } + DebugConsole.Log($"EOS UpdateOwnedSessionAttributes result: {result}"); + }); + + + void SetAttributeValue(Identifier attributeKey, object value) + { + string valueStr = value.ToString() ?? ""; + + if (attributeKey == "contentpackages" && value is IEnumerable contentPackages) + { + int contentPackageIndex = 0; + foreach (var contentPackage in contentPackages) + { + ownedSession.Attributes[$"contentpackage{contentPackageIndex}".ToIdentifier()] + = new ServerListContentPackageInfo(contentPackage).ToString(); + contentPackageIndex++; + } + } + else + { + ownedSession.Attributes[attributeKey] = valueStr; + } + } + + } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs b/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs index b68a159dc..a21bc4cf2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Events/EventSet.cs @@ -465,17 +465,17 @@ namespace Barotrauma public float GetCommonness(Level level) { - if (level.GenerationParams?.Identifier != null && + if (level.GenerationParams?.Identifier is { IsEmpty: false } && OverrideCommonness.TryGetValue(level.GenerationParams.Identifier, out float generationParamsCommonness)) { return generationParamsCommonness; } - else if (level.StartOutpost?.Info.OutpostGenerationParams?.Identifier != null && + else if (level.StartOutpost?.Info.OutpostGenerationParams?.Identifier is { IsEmpty: false } && OverrideCommonness.TryGetValue(level.StartOutpost.Info.OutpostGenerationParams.Identifier, out float startOutpostParamsCommonness)) { return startOutpostParamsCommonness; } - else if (level.EndOutpost?.Info.OutpostGenerationParams?.Identifier != null && + else if (level.EndOutpost?.Info.OutpostGenerationParams?.Identifier is { IsEmpty: false } && OverrideCommonness.TryGetValue(level.EndOutpost.Info.OutpostGenerationParams.Identifier, out float endOutpostParamsCommonness)) { return endOutpostParamsCommonness; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/IEnumerableExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Extensions/IEnumerableExtensions.cs index 6453c4d52..4a12a67c6 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Extensions/IEnumerableExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Extensions/IEnumerableExtensions.cs @@ -185,27 +185,6 @@ namespace Barotrauma.Extensions if (value != null) { source.Add(value); } } - public static ImmutableDictionary ToImmutableDictionary(this IEnumerable<(TKey, TValue)> enumerable) - { - return enumerable.ToDictionary().ToImmutableDictionary(); - } - - public static Dictionary ToDictionary(this IEnumerable<(TKey, TValue)> enumerable) - { - var dictionary = new Dictionary(); - foreach (var (k,v) in enumerable) - { - dictionary.Add(k, v); - } - return dictionary; - } - - public static Dictionary ToMutable(this ImmutableDictionary immutableDictionary) - { - if (immutableDictionary == null) { return null; } - return new Dictionary(immutableDictionary); - } - public static NetCollection ToNetCollection(this IEnumerable enumerable) => new NetCollection(enumerable.ToImmutableArray()); /// @@ -236,87 +215,5 @@ namespace Barotrauma.Extensions public static IReadOnlyList ListConcat(this IEnumerable self, IEnumerable other) => new ListConcat(self, other); - - /// - /// Returns the maximum element in a given enumerable, or null if there - /// aren't any elements in the input. - /// - /// Input collection - /// Maximum element or null - public static T? MaxOrNull(this IEnumerable enumerable) where T : struct, IComparable - { - T? retVal = null; - foreach (T v in enumerable) - { - if (!retVal.HasValue || v.CompareTo(retVal.Value) > 0) { retVal = v; } - } - return retVal; - } - - public static TOut? MaxOrNull(this IEnumerable enumerable, Func conversion) - where TOut : struct, IComparable - => enumerable.Select(conversion).MaxOrNull(); - - public static int FindIndex(this IReadOnlyList list, Predicate predicate) - { - for (int i=0; i - /// Same as FirstOrDefault but will always return null instead of default(T) when no element is found - /// - public static T? FirstOrNull(this IEnumerable source, Func predicate) where T : struct - { - if (source.FirstOrDefault(predicate) is var first && !first.Equals(default(T))) - { - return first; - } - - return null; - } - - public static T? FirstOrNull(this IEnumerable source) where T : struct - { - if (source.FirstOrDefault() is var first && !first.Equals(default(T))) - { - return first; - } - - return null; - } - - public static IEnumerable NotNull(this IEnumerable source) where T : struct - => source - .Where(nullable => nullable.HasValue) - .Select(nullable => nullable.Value); - - public static IEnumerable NotNull(this IEnumerable source) where T : class - => source - .Where(nullable => nullable != null) - .Select(nullable => nullable!); - - public static IEnumerable NotNone(this IEnumerable> source) - { - foreach (var o in source) - { - if (o.TryUnwrap(out var v)) { yield return v; } - } - } - - public static IEnumerable Successes( - this IEnumerable> source) - => source - .OfType>() - .Select(s => s.Value); - - public static IEnumerable Failures( - this IEnumerable> source) - => source - .OfType>() - .Select(f => f.Error); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringExtensions.cs index c6c3e3a92..9e72f54f9 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringExtensions.cs @@ -6,25 +6,13 @@ namespace Barotrauma { static class StringExtensions { - public static string FallbackNullOrEmpty(this string s, string fallback) => string.IsNullOrEmpty(s) ? fallback : s; - - public static bool IsNullOrEmpty([NotNullWhen(returnValue: false)]this string? s) => string.IsNullOrEmpty(s); - public static bool IsNullOrWhiteSpace([NotNullWhen(returnValue: false)]this string? s) => string.IsNullOrWhiteSpace(s); + public static bool IsNullOrEmpty([NotNullWhen(returnValue: false)]this ContentPath? p) => p?.IsPathNullOrEmpty() ?? true; public static bool IsNullOrWhiteSpace([NotNullWhen(returnValue: false)]this ContentPath? p) => p?.IsPathNullOrWhiteSpace() ?? true; + public static bool IsNullOrEmpty([NotNullWhen(returnValue: false)]this LocalizedString? s) => s is null || string.IsNullOrEmpty(s.Value); public static bool IsNullOrWhiteSpace([NotNullWhen(returnValue: false)]this LocalizedString? s) => s is null || string.IsNullOrWhiteSpace(s.Value); public static bool IsNullOrEmpty([NotNullWhen(returnValue: false)]this RichString? s) => s is null || s.NestedStr.IsNullOrEmpty(); public static bool IsNullOrWhiteSpace([NotNullWhen(returnValue: false)]this RichString? s) => s is null || s.NestedStr.IsNullOrWhiteSpace(); - - public static string RemoveFromEnd(this string s, string substr, StringComparison stringComparison = StringComparison.Ordinal) - => s.EndsWith(substr, stringComparison) ? s.Substring(0, s.Length - substr.Length) : s; - - public static bool IsTrueString(this string s) - => s.Length == 4 - && s[0] is 'T' or 't' - && s[1] is 'R' or 'r' - && s[2] is 'U' or 'u' - && s[3] is 'E' or 'e'; } } \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsConsent.cs b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsConsent.cs index a6ba4d40d..fe540c172 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsConsent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsConsent.cs @@ -1,7 +1,8 @@ -#nullable enable +#nullable enable using Barotrauma.Steam; using RestSharp; using System; +using System.Linq; using System.Net; using System.Threading.Tasks; @@ -13,7 +14,7 @@ namespace Barotrauma /// The protocol used to communicate with the remote consent server may change. /// This number tells the server which version the game is using so we can implement backwards-compatibility. /// - private const string RemoteRequestVersion = "2"; + private const string RemoteRequestVersion = "3"; public enum Consent { @@ -47,19 +48,66 @@ namespace Barotrauma public static bool SendUserStatistics => UserConsented == Consent.Yes && loadedImplementation != null; - private static bool consentTextAvailable + private static bool ConsentTextAvailable => TextManager.ContainsTag("statisticsconsentheader") && TextManager.ContainsTag("statisticsconsenttext"); private const string consentServerUrl = "https://barotraumagame.com/baromaster/"; private const string consentServerFile = "consentserver.php"; - private static async Task GetAuthTicket() + enum Platform { - var ticketOption = await SteamManager.GetAuthTicketForGameAnalyticsConsent(); - if (!ticketOption.TryUnwrap(out var authTicket) || authTicket.Data is null) { return ""; } - //convert byte array to hex - return BitConverter.ToString(authTicket.Data).Replace("-", ""); + Steam, + EOS, + None + } + + private class AuthTicket + { + public readonly string Token; + public readonly Platform Platform; + + public AuthTicket(string token, Platform platform) + { + Token = token ?? string.Empty; + Platform = platform; + } + } + + private static async Task GetAuthTicket() + { + if (SteamManager.IsInitialized) + { + return await GetSteamAuthTicket(); + } + else if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + return await GetEOSAuthTicket(); + } + return new AuthTicket(string.Empty, Platform.None); + } + + private static async Task GetSteamAuthTicket() + { + var authTicket = await SteamManager.GetAuthTicketForGameAnalyticsConsent(); + return authTicket.TryUnwrap(out var ticketUnwrapped) && ticketUnwrapped.Data is { Length: > 0 } + ? new AuthTicket(ToolBoxCore.ByteArrayToHexString(ticketUnwrapped.Data), Platform.Steam) //convert byte array to hex + : throw new Exception("Could not retrieve Steamworks authentication ticket for GameAnalytics"); + } + + private static async Task GetEOSAuthTicket() + { + var puid = EosInterface.IdQueries.GetLoggedInPuids().First(); + var tokenResult = EosInterface.EosIdToken.FromProductUserId(puid); + if (tokenResult.TryUnwrapFailure(out var error)) + { + throw new Exception($"Could not retrieve EOS authentication ticket for GameAnalytics. {error}"); + } + else if (tokenResult.TryUnwrapSuccess(out var token)) + { + return new AuthTicket(token.JsonWebToken.ToString(), Platform.EOS); + } + throw new UnreachableCodeException(); } /// @@ -92,7 +140,9 @@ namespace Barotrauma if (consent == Consent.Ask) { - CreateConsentPrompt(); +#if CLIENT + GameMain.ExecuteAfterContentFinishedLoading(CreateConsentPrompt); +#endif } if (consent != Consent.No && consent != Consent.Yes) @@ -129,20 +179,24 @@ namespace Barotrauma /// private static async Task SendAnswerToRemoteDatabase(Consent consent) { - string authTicketStr; + AuthTicket authTicket; try { - authTicketStr = await GetAuthTicket(); + authTicket = await GetAuthTicket(); } catch (Exception e) { - DebugConsole.ThrowError("Error in GameAnalyticsManager.SetConsent. Could not get a Steam authentication ticket.", e); + DebugConsole.ThrowError($"Error in {nameof(GameAnalyticsManager)}.{nameof(SendAnswerToRemoteDatabase)}. Could not get an authentication ticket.", e); return false; } - - if (string.IsNullOrEmpty(authTicketStr)) + if (authTicket.Platform == Platform.None) { - DebugConsole.ThrowError("Error in GameAnalyticsManager.SetContent. Steam authentication ticket was empty."); + DebugConsole.AddWarning($"Error in {nameof(GameAnalyticsManager)}.{nameof(SendAnswerToRemoteDatabase)}. Not logged in to any platform."); + return false; + } + if (string.IsNullOrEmpty(authTicket.Token)) + { + DebugConsole.ThrowError($"Error in {nameof(GameAnalyticsManager)}.{nameof(SendAnswerToRemoteDatabase)}. {authTicket.Platform} authentication ticket was empty."); return false; } @@ -152,10 +206,18 @@ namespace Barotrauma var client = new RestClient(consentServerUrl); var request = new RestRequest(consentServerFile, Method.GET); - request.AddParameter("authticket", authTicketStr); - request.AddParameter("action", "setconsent"); - request.AddParameter("consent", consent == Consent.Yes ? 1 : 0); + request.AddParameter("authticket", authTicket.Token); + if (consent == Consent.Ask) + { + request.AddParameter("action", "resetconsent"); + } + else + { + request.AddParameter("action", "setconsent"); + request.AddParameter("consent", consent == Consent.Yes ? 1 : 0); + } request.AddParameter("request_version", RemoteRequestVersion); + request.AddParameter("platform", authTicket.Platform); response = await client.ExecuteAsync(request, Method.GET); } @@ -175,6 +237,18 @@ namespace Barotrauma return true; } + public static void ResetConsent() + { + TaskPool.Add( + "GameAnalyticsConsent.ResetConsentInternal", + SendAnswerToRemoteDatabase(Consent.Ask), + t => + { + if (!t.TryGetResult(out bool success) || !success) { return; } + DebugConsole.NewMessage("Reset GameAnalytics consent."); + }); + } + static partial void CreateConsentPrompt(); public static void InitIfConsented() @@ -183,15 +257,15 @@ namespace Barotrauma return; #endif - if (!consentTextAvailable) + if (!ConsentTextAvailable) { SetConsent(Consent.Unknown); return; } - if (!SteamManager.IsInitialized) + if (!SteamManager.IsInitialized && EosInterface.IdQueries.GetLoggedInPuids() is not { Length: > 0 }) { - DebugConsole.AddWarning("Error in GameAnalyticsManager.GetConsent: Could not get a Steam authentication ticket (not connected to Steam)."); + DebugConsole.AddWarning("Error in GameAnalyticsManager.GetConsent: Could not get a Steam or EOS authentication ticket (not connected to Steam or EOS)."); SetConsent(Consent.Error); return; } @@ -208,20 +282,25 @@ namespace Barotrauma private static async Task RequestAnswerFromRemoteDatabase() { - static void error(string reason, Exception exception) + static void error(string reason, Exception? exception) { - DebugConsole.ThrowError($"Error in GameAnalyticsManager.GetConsent: {reason}", exception); + DebugConsole.ThrowError($"Error in {nameof(GameAnalyticsManager)}.{nameof(RequestAnswerFromRemoteDatabase)}: {reason}", exception); SetConsent(Consent.Error); } - string authTicketStr; + AuthTicket authTicket; try { - authTicketStr = await GetAuthTicket(); + authTicket = await GetAuthTicket(); } catch (Exception e) { - error("Could not get a Steam authentication ticket.", e); + error("Could not get an authentication ticket.", e); + return Consent.Error; + } + if (authTicket.Platform == Platform.None) + { + error($"Could not get an authentication ticket. Not logged in to any platform.", exception: null); return Consent.Error; } @@ -237,9 +316,10 @@ namespace Barotrauma } var request = new RestRequest(consentServerFile, Method.GET); - request.AddParameter("authticket", authTicketStr); + request.AddParameter("authticket", authTicket.Token); request.AddParameter("action", "getconsent"); request.AddParameter("request_version", RemoteRequestVersion); + request.AddParameter("platform", authTicket.Platform); IRestResponse response; try diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs index fbb52eaea..27ff2c57e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameAnalytics/GameAnalyticsManager.cs @@ -1,5 +1,6 @@ #nullable enable using Barotrauma.IO; +using Barotrauma.Steam; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; @@ -51,6 +52,13 @@ namespace Barotrauma Difficulty90to100, } + public enum CustomDimensions03 + { + UnknownPlatform, + Steam, + EGS + } + public enum ResourceCurrency { Money @@ -149,6 +157,14 @@ namespace Barotrauma internal void ConfigureAvailableResourceCurrencies(params ResourceCurrency[] customDimensions) => configureAvailableResourceCurrencies(customDimensions.Select(d => d.ToString()).ToArray()); + private readonly Action configureAvailableCustomDimensions03; + internal void ConfigureAvailableCustomDimensions03(params CustomDimensions03[] customDimensions) + => configureAvailableCustomDimensions03(customDimensions.Select(d => d.ToString()).ToArray()); + + private readonly Action setCustomDimension03; + internal void SetCustomDimension03(string dimension03) + => setCustomDimension03(dimension03); + private readonly Action configureAvailableResourceItemTypes; internal void ConfigureAvailableResourceItemTypes(params string[] resourceItemTypes) => configureAvailableResourceItemTypes(resourceItemTypes); @@ -238,9 +254,9 @@ namespace Barotrauma { if (resolvingDependency) { return null; } resolvingDependency = true; - Assembly dep = context.LoadFromAssemblyPath(GetAssemblyPath(dependencyName.Name ?? throw new Exception("Dependency name was null"))); + Assembly dependency = context.LoadFromAssemblyPath(GetAssemblyPath(dependencyName.Name ?? throw new Exception("Dependency name was null"))); resolvingDependency = false; - return dep; + return dependency; } internal Implementation() @@ -297,7 +313,10 @@ namespace Barotrauma new Type[] { typeof(string) })); configureAvailableCustomDimensions02 = Call(getMethod(nameof(ConfigureAvailableCustomDimensions02), new Type[] { typeof(string[]) })); - + configureAvailableCustomDimensions03 = Call(getMethod(nameof(ConfigureAvailableCustomDimensions03), + new Type[] { typeof(string[]) })); + setCustomDimension03 = Call(getMethod(nameof(SetCustomDimension03), + new Type[] { typeof(string) })); configureAvailableResourceCurrencies = Call(getMethod(nameof(ConfigureAvailableResourceCurrencies), new Type[] { typeof(string[]) })); configureAvailableResourceItemTypes = Call(getMethod(nameof(ConfigureAvailableResourceItemTypes), @@ -424,6 +443,12 @@ namespace Barotrauma loadedImplementation?.SetCustomDimension01(dimension.ToString()); } + public static void SetCustomDimension03(CustomDimensions03 dimension) + { + if (!SendUserStatistics) { return; } + loadedImplementation?.SetCustomDimension03(dimension.ToString()); + } + public static void SetCurrentLevel(LevelData levelData) { if (!SendUserStatistics) { return; } @@ -509,6 +534,7 @@ namespace Barotrauma + buildConfiguration); loadedImplementation?.ConfigureAvailableCustomDimensions01(Enum.GetValues(typeof(CustomDimensions01)).Cast().ToArray()); loadedImplementation?.ConfigureAvailableCustomDimensions02(Enum.GetValues(typeof(CustomDimensions02)).Cast().ToArray()); + loadedImplementation?.ConfigureAvailableCustomDimensions03(Enum.GetValues(typeof(CustomDimensions03)).Cast().ToArray()); loadedImplementation?.ConfigureAvailableResourceCurrencies(Enum.GetValues(typeof(ResourceCurrency)).Cast().ToArray()); loadedImplementation?.ConfigureAvailableResourceItemTypes( Enum.GetValues(typeof(MoneySink)).Cast().Select(s => s.ToString()).Union(Enum.GetValues(typeof(MoneySource)).Cast().Select(s => s.ToString())).ToArray()); @@ -521,6 +547,19 @@ namespace Barotrauma + (exeHash?.ShortRepresentation ?? "Unknown") + ":" + AssemblyInfo.GitRevision + ":" + buildConfiguration); + + SetCustomDimension01(ContentPackageManager.ModsEnabled ? CustomDimensions01.Modded : CustomDimensions01.Vanilla); + + CustomDimensions03 platform = CustomDimensions03.UnknownPlatform; + if (SteamManager.IsInitialized) + { + platform = CustomDimensions03.Steam; + } + else if (EosInterface.IdQueries.IsLoggedIntoEosConnect) + { + platform = CustomDimensions03.EGS; + } + SetCustomDimension03(platform); } catch (Exception e) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/Data/CampaignMetadata.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/Data/CampaignMetadata.cs index b8c680577..d3167c3e2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/Data/CampaignMetadata.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/Data/CampaignMetadata.cs @@ -32,7 +32,7 @@ namespace Barotrauma continue; } - Type? type = Type.GetType(valueType); + Type? type = ReflectionUtils.GetType(valueType); if (type == null) { @@ -62,7 +62,7 @@ namespace Barotrauma { DebugConsole.Log($"Set the value \"{identifier}\" to {value}"); - SteamAchievementManager.OnCampaignMetadataSet(identifier, value, unlockClients: true); + AchievementManager.OnCampaignMetadataSet(identifier, value, unlockClients: true); if (!data.ContainsKey(identifier)) { @@ -135,7 +135,7 @@ namespace Barotrauma element.Add(new XElement("Data", new XAttribute("key", key), new XAttribute("value", valueStr), - new XAttribute("type", value.GetType()))); + new XAttribute("type", value.GetType().FullName ?? ""))); } #if DEBUG DebugConsole.Log(element.ToString()); diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs index a3bce5575..e3f4b4ade 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameModes/MultiPlayerCampaign.cs @@ -156,17 +156,15 @@ namespace Barotrauma if (CheatsEnabled) { DebugConsole.CheatsEnabled = true; -#if USE_STEAM - if (!SteamAchievementManager.CheatsEnabled) + if (!AchievementManager.CheatsEnabled) { - SteamAchievementManager.CheatsEnabled = true; + AchievementManager.CheatsEnabled = true; #if CLIENT - new GUIMessageBox("Cheats enabled", "Cheat commands have been enabled on the server. You will not receive Steam Achievements until you restart the game."); + new GUIMessageBox("Cheats enabled", "Cheat commands have been enabled on the server. You will not receive achievements until you restart the game."); #else DebugConsole.NewMessage("Cheat commands have been enabled.", Color.Red); #endif } -#endif } foreach (var subElement in element.Elements()) diff --git a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs index 38a259fd5..aa06066e2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/GameSession/GameSession.cs @@ -551,7 +551,7 @@ namespace Barotrauma } #endif #if CLIENT - if (campaignMode != null && levelData != null) { SteamAchievementManager.OnBiomeDiscovered(levelData.Biome); } + if (campaignMode != null && levelData != null) { AchievementManager.OnBiomeDiscovered(levelData.Biome); } var existingRoundSummary = GUIMessageBox.MessageBoxes.Find(mb => mb.UserData is RoundSummary)?.UserData as RoundSummary; if (existingRoundSummary?.ContinueButton != null) @@ -653,7 +653,7 @@ namespace Barotrauma ObjectiveManager.ResetObjectives(); #endif EventManager?.StartRound(Level.Loaded); - SteamAchievementManager.OnStartRound(); + AchievementManager.OnStartRound(); GameMode.ShowStartMessage(); @@ -936,7 +936,7 @@ namespace Barotrauma ObjectiveManager.ResetUI(); CharacterHUD.ClearBossProgressBars(); #endif - SteamAchievementManager.OnRoundEnded(this); + AchievementManager.OnRoundEnded(this); #if SERVER GameMain.Server?.TraitorManager?.EndRound(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Repairable.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Repairable.cs index 873325957..48a127a8d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Repairable.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Repairable.cs @@ -497,7 +497,7 @@ namespace Barotrauma.Items.Components { CurrentFixer.Info?.ApplySkillGain(skill.Identifier, SkillSettings.Current.SkillIncreasePerRepair); } - SteamAchievementManager.OnItemRepaired(item, CurrentFixer); + AchievementManager.OnItemRepaired(item, CurrentFixer); CurrentFixer.CheckTalents(AbilityEffectType.OnRepairComplete, new AbilityRepairable(item)); } if (CurrentFixer?.SelectedItem == item) { CurrentFixer.SelectedItem = null; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/ColorComponent.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/ColorComponent.cs index fb610a6ce..b9b404fdc 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/ColorComponent.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/Components/Signal/ColorComponent.cs @@ -35,7 +35,7 @@ namespace Barotrauma.Items.Components if (UseHSV) { - Color hsvColor = ToolBox.HSVToRGB(signalR, signalG, signalB); + Color hsvColor = ToolBoxCore.HSVToRGB(signalR, signalG, signalB); signalR = hsvColor.R; signalG = hsvColor.G; signalB = hsvColor.B; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs b/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs index 447f3b41d..d9a727c97 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Items/ItemPrefab.cs @@ -150,7 +150,7 @@ namespace Barotrauma { ItemPrefabIdentifier = itemPrefab; using MD5 md5 = MD5.Create(); - UintIdentifier = ToolBox.IdentifierToUint32Hash(itemPrefab, md5); + UintIdentifier = ToolBoxCore.IdentifierToUint32Hash(itemPrefab, md5); } public override string ToString() @@ -197,7 +197,7 @@ namespace Barotrauma { Tag = tag; using MD5 md5 = MD5.Create(); - UintIdentifier = ToolBox.IdentifierToUint32Hash(tag, md5); + UintIdentifier = ToolBoxCore.IdentifierToUint32Hash(tag, md5); } public override string ToString() @@ -349,7 +349,7 @@ namespace Barotrauma private uint GenerateHash() { using var md5 = MD5.Create(); - uint outputId = ToolBox.IdentifierToUint32Hash(TargetItemPrefabIdentifier, md5); + uint outputId = ToolBoxCore.IdentifierToUint32Hash(TargetItemPrefabIdentifier, md5); var requiredItems = string.Join(':', RequiredItems .Select(static i => $"{i.UintIdentifier}:{i.Amount}") @@ -358,7 +358,7 @@ namespace Barotrauma var requiredSkills = string.Join(':', RequiredSkills.Select(s => $"{s.Identifier}:{s.Level}")); - uint retVal = ToolBox.StringToUInt32Hash($"{Amount}|{outputId}|{RequiredTime}|{RequiresRecipe}|{requiredItems}|{requiredSkills}", md5); + uint retVal = ToolBoxCore.StringToUInt32Hash($"{Amount}|{outputId}|{RequiredTime}|{RequiresRecipe}|{requiredItems}|{requiredSkills}", md5); if (retVal == 0) { retVal = 1; } return retVal; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs index 5db866ad8..592e70844 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Map/Levels/Level.cs @@ -2046,7 +2046,8 @@ namespace Barotrauma caveStartPos.ToVector2(), caveEndPos.ToVector2(), iterations: 3, offsetAmount: Vector2.Distance(caveStartPos.ToVector2(), caveEndPos.ToVector2()) * 0.75f, - bounds: caveArea); + bounds: caveArea, + rng: Rand.GetRNG(Rand.RandSync.ServerAndClient)); if (!caveSegments.Any()) { return; } @@ -2066,7 +2067,8 @@ namespace Barotrauma branchStartPos, branchEndPos, iterations: 3, offsetAmount: Vector2.Distance(branchStartPos, branchEndPos) * 0.75f, - bounds: caveArea); + bounds: caveArea, + rng: Rand.GetRNG(Rand.RandSync.ServerAndClient)); if (!branchSegments.Any()) { continue; } var branch = new Tunnel(TunnelType.Cave, SegmentsToNodes(branchSegments), 150, parentBranch); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ChildServerRelay.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ChildServerRelay.cs index 9f7dafe82..909aa78ba 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/ChildServerRelay.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ChildServerRelay.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Concurrent; -using System.Linq; +using System.Collections.Generic; +using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; -using Barotrauma.Extensions; #if SERVER using PipeType = System.IO.Pipes.AnonymousPipeClientStream; @@ -121,6 +121,7 @@ namespace Barotrauma.Networking readCancellationToken?.Cancel(); return Option.None(); } + try { if (readTask.IsCompleted || readTask.Wait(timeOutMilliseconds, readCancellationToken.Token)) @@ -327,7 +328,36 @@ namespace Barotrauma.Networking writeManualResetEvent.Set(); } - public static bool Read(out byte[] msg) + private static readonly Stopwatch stopwatch = new Stopwatch(); + private const int MaxMilliseconds = 8; + + public static IEnumerable Read() + { + stopwatch.Restart(); + + // To avoid the stopwatch somehow experiencing magical overhead that makes it not even + // start the loop within 8ms, use this bool to force at least one iteration. + bool hasIteratedAtLeastOnce = false; + + // If it's taken more than 8 milliseconds to read dequeued messages, take + // a break from reading and allow all of the other logic to run for a tick. + // Otherwise the server may overwhelm the host client with redundant messages + // that are being read too slowly. + while (!hasIteratedAtLeastOnce || stopwatch.ElapsedMilliseconds < MaxMilliseconds) + { + hasIteratedAtLeastOnce = true; + if (!ReadSingleMessage(out var msg)) + { + // No more messages available to dequeue, we don't need + // to reach 8 milliseconds to know we're done here + break; + } + yield return msg; + } + stopwatch.Stop(); + } + + private static bool ReadSingleMessage(out byte[] msg) { if (HasShutDown) { msg = null; return false; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs index 4c9adeb13..7432c97cf 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/NetworkMember.cs @@ -65,6 +65,7 @@ namespace Barotrauma.Networking PERMISSIONS, //tell the client which special permissions they have (if any) ACHIEVEMENT, //give the client a steam achievement + ACHIEVEMENT_STAT, //increment stat for an achievement CHEATS_ENABLED, //tell the clients whether cheats are on or off CAMPAIGN_SETUP_INFO, @@ -147,7 +148,7 @@ namespace Barotrauma.Networking ServerCrashed, ServerFull, AuthenticationRequired, - SteamAuthenticationFailed, + AuthenticationFailed, SessionTaken, TooManyFailedLogins, InvalidName, diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountInfo.cs index 608486f0a..5ef184891 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountInfo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountInfo.cs @@ -18,15 +18,16 @@ namespace Barotrauma.Networking /// Other user IDs that this user might be closely tied to, /// such as the owner of the current copy of Barotrauma /// - #warning TODO: make ImmutableArray once feature/inetserializablestruct-improvements gets merged to dev - public readonly AccountId[] OtherMatchingIds; + public readonly ImmutableArray OtherMatchingIds; + + public bool IsNone => AccountId.IsNone() && OtherMatchingIds.Length == 0; public AccountInfo(AccountId accountId, params AccountId[] otherIds) : this(Option.Some(accountId), otherIds) { } public AccountInfo(Option accountId, params AccountId[] otherIds) { AccountId = accountId; - OtherMatchingIds = otherIds.Where(id => !accountId.ValueEquals(id)).ToArray(); + OtherMatchingIds = otherIds.Where(id => !accountId.ValueEquals(id)).ToImmutableArray(); } public bool Matches(AccountId accountId) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/AuthenticationTicket.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/AuthenticationTicket.cs new file mode 100644 index 000000000..656681388 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/AuthenticationTicket.cs @@ -0,0 +1,65 @@ +#nullable enable +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Steam; + +namespace Barotrauma.Networking; + +public enum AuthenticationTicketKind +{ + SteamAuthTicketForSteamHost = 0, + SteamAuthTicketForEosHost = 1, + EgsOwnershipToken = 2 +} + +[NetworkSerialize(ArrayMaxSize = UInt16.MaxValue)] +readonly record struct AuthenticationTicket( + AuthenticationTicketKind Kind, + ImmutableArray Data) : INetSerializableStruct +{ + public static async Task> Create(Endpoint serverEndpoint) + { + if (SteamManager.IsInitialized && SteamManager.GetSteamId().TryUnwrap(out var steamId)) + { + if (serverEndpoint is EosP2PEndpoint) + { + var authTicket = await SteamManager.GetAuthTicketForEosHostAuth(); + return authTicket + .Bind(t => t.Data != null ? Option.Some(t.Data) : Option.None) + .Select(data => new AuthenticationTicket(AuthenticationTicketKind.SteamAuthTicketForEosHost, data.ToImmutableArray())); + } + else + { + var authTicket = SteamManager.GetAuthSessionTicketForSteamHost(serverEndpoint); + var steamIdBytes = BitConverter.GetBytes(steamId.Value); + return authTicket + .Bind(t => t.Data != null ? Option.Some(t.Data) : Option.None) + .Select(data => new AuthenticationTicket( + AuthenticationTicketKind.SteamAuthTicketForSteamHost, + steamIdBytes.Concat(data).ToImmutableArray())); + } + } + + if (EosInterface.IdQueries.GetLoggedInPuids() is { Length: > 0 } puids) + { + var externalAccountIdsResult = await EosInterface.IdQueries.GetSelfExternalAccountIds(puids[0]); + if (externalAccountIdsResult.TryUnwrapSuccess(out var externalAccountIds)) + { + var epicAccountIdOption = externalAccountIds.OfType().FirstOrNone(); + if (epicAccountIdOption.TryUnwrap(out var epicAccountId)) + { + return (await EosInterface.Ownership.GetGameOwnershipToken(epicAccountId)) + .Select(t => new AuthenticationTicket( + AuthenticationTicketKind.EgsOwnershipToken, + Encoding.UTF8.GetBytes(t.Jwt.ToString()).ToImmutableArray())); + } + } + } + + return Option.None; + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/Authenticator.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/Authenticator.cs new file mode 100644 index 000000000..3f9c92729 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/Authenticator.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Barotrauma.Steam; + +namespace Barotrauma.Networking; + +abstract class Authenticator +{ + public abstract Task VerifyTicket(AuthenticationTicket ticket); + public abstract void EndAuthSession(AccountId accountId); + + public static ImmutableDictionary GetAuthenticatorsForHost(Option ownerEndpointOption) + { + var authenticators = new Dictionary(); + + if (EosInterface.Core.IsInitialized) + { + // Every kind of host should be able to do EOS ID Token authentication if they have EOS enabled + authenticators.Add(AuthenticationTicketKind.EgsOwnershipToken, new EgsOwnershipTokenAuthenticator()); + + if (ownerEndpointOption.TryUnwrap(out var ownerEndpoint) && ownerEndpoint is EosP2PEndpoint) + { + // EOS P2P hosts do not have access to Steamworks + authenticators.Add(AuthenticationTicketKind.SteamAuthTicketForEosHost, new SteamAuthTicketForEosHostAuthenticator()); + } + } + + if (!(ownerEndpointOption.TryUnwrap(out var ownerEndpoint2) && ownerEndpoint2 is EosP2PEndpoint) && SteamManager.IsInitialized) + { + // Steam P2P hosts and dedicated servers have access to Steamworks + authenticators.Add(AuthenticationTicketKind.SteamAuthTicketForSteamHost, new SteamAuthTicketForSteamHostAuthenticator()); + } + + return authenticators.ToImmutableDictionary(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/EgsOwnershipTokenAuthenticator.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/EgsOwnershipTokenAuthenticator.cs new file mode 100644 index 000000000..e3e2201a0 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/EgsOwnershipTokenAuthenticator.cs @@ -0,0 +1,21 @@ +using System.Text; +using System.Threading.Tasks; + +namespace Barotrauma.Networking; + +sealed class EgsOwnershipTokenAuthenticator : Authenticator +{ + public override async Task VerifyTicket(AuthenticationTicket ticket) + { + var jwtOption = JsonWebToken.Parse(Encoding.UTF8.GetString(ticket.Data.AsSpan())); + + if (!jwtOption.TryUnwrap(out var jwt)) { return AccountInfo.None; } + var ownershipToken = new EosInterface.Ownership.Token(jwt); + var accountIdOption = await ownershipToken.Verify(); + + if (!accountIdOption.TryUnwrap(out var accountId)) { return AccountInfo.None; } + return new AccountInfo(accountId); + } + + public override void EndAuthSession(AccountId accountId) { /* do nothing */ } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForEosHostAuthenticator.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForEosHostAuthenticator.cs new file mode 100644 index 000000000..1202e1052 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForEosHostAuthenticator.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using RestSharp; + +namespace Barotrauma.Networking; + +sealed class SteamAuthTicketForEosHostAuthenticator : Authenticator +{ + #warning FIXME change URL back to the non-experimental one once this passes QA + private const string consentServerUrl = "https://barotraumagame.com/baromaster/experimental/"; + private const string consentServerFile = "getOwnerSteamId.php"; + private const int RemoteRequestVersion = 1; + + public override async Task VerifyTicket(AuthenticationTicket ticket) + { + string ticketData = ToolBoxCore.ByteArrayToHexString(ticket.Data); + + var client = new RestClient(consentServerUrl); + + var request = new RestRequest(consentServerFile, Method.GET); + request.AddParameter("authticket", ticketData); + request.AddParameter("request_version", RemoteRequestVersion); + + var response = await client.ExecuteAsync(request, Method.GET); + if (!response.IsSuccessful) { return AccountInfo.None; } + + try + { + var jsonDoc = JsonDocument.Parse(response.Content); + Option steamId = Option.None; + Option ownerSteamId = Option.None; + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + if (!property.Name.ToIdentifier().Contains("SteamId")) { continue; } + var accountIdOption = SteamId.Parse(property.Value.GetString() ?? ""); + if (accountIdOption.IsNone()) { continue; } + if (property.Name.ToIdentifier() == "SteamId") + { + steamId = accountIdOption; + } + else if (property.Name.ToIdentifier() == "OwnerSteamId") + { + ownerSteamId = accountIdOption; + } + } + var otherIds = ownerSteamId.TryUnwrap(out var id) ? new AccountId[] { id } : Array.Empty(); + return new AccountInfo(steamId.Select(static id => (AccountId)id), otherIds); + } + catch + { + return AccountInfo.None; + } + } + + public override void EndAuthSession(AccountId accountId) { /* do nothing */ } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForSteamHostAuthenticator.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForSteamHostAuthenticator.cs new file mode 100644 index 000000000..2c885f215 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Auth/SteamAuthTicketForSteamHostAuthenticator.cs @@ -0,0 +1,77 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Steam; + +namespace Barotrauma.Networking; + +#if CLIENT +using SteamAuthSessionInterface = Steamworks.SteamUser; +#else +using SteamAuthSessionInterface = Steamworks.SteamServer; +#endif + +sealed class SteamAuthTicketForSteamHostAuthenticator : Authenticator +{ + private static Steamworks.BeginAuthResult BeginAuthSession(Steamworks.AuthTicket authTicket, SteamId clientSteamId) + { + if (!SteamManager.IsInitialized) { return Steamworks.BeginAuthResult.ServerNotConnectedToSteam; } + if (authTicket.Data is null) { return Steamworks.BeginAuthResult.InvalidTicket; } + + DebugConsole.Log("Authenticating Steam client " + clientSteamId); + Steamworks.BeginAuthResult startResult = SteamAuthSessionInterface.BeginAuthSession(authTicket.Data, clientSteamId.Value); + if (startResult != Steamworks.BeginAuthResult.OK) + { + DebugConsole.Log($"Steam authentication failed: failed to start auth session ({startResult})"); + } + + return startResult; + } + + private static void EndAuthSession(SteamId clientSteamId) + { + if (!SteamManager.IsInitialized) { return; } + + DebugConsole.Log($"Ending auth session with Steam client {clientSteamId}"); + SteamAuthSessionInterface.EndAuthSession(clientSteamId.Value); + } + + public override async Task VerifyTicket(AuthenticationTicket ticket) + { + if (ticket.Data.Length < 8) { return AccountInfo.None; } + + var ticketData = ticket.Data.ToArray(); + var steamAuthTicket = new Steamworks.AuthTicket { Data = ticketData[8..] }; + var steamId = new SteamId(BitConverter.ToUInt64(ticketData.AsSpan()[..8])); + + using var janitor = Janitor.Start(); + + (Steamworks.AuthResponse AuthResponse, SteamId OwnerSteamId)? authResult = null; + void onValidateAuthTicketResponse(Steamworks.SteamId clientId, Steamworks.SteamId ownerClientId, Steamworks.AuthResponse response) + { + if (clientId != steamId.Value) { response = Steamworks.AuthResponse.AuthTicketInvalid; } + authResult = (response, new SteamId(ownerClientId)); + } + + SteamAuthSessionInterface.OnValidateAuthTicketResponse += onValidateAuthTicketResponse; + janitor.AddAction(() => SteamAuthSessionInterface.OnValidateAuthTicketResponse -= onValidateAuthTicketResponse); + var beginAuthSessionResult = BeginAuthSession(steamAuthTicket, steamId); + + if (beginAuthSessionResult != Steamworks.BeginAuthResult.OK) { return AccountInfo.None; } + + while (authResult is null) + { + await Task.Delay(32); + } + if (authResult.Value.AuthResponse != Steamworks.AuthResponse.OK) { return AccountInfo.None; } + + return new AccountInfo(steamId, authResult.Value.OwnerSteamId); + } + + public override void EndAuthSession(AccountId accountId) + { + if (accountId is not SteamId steamId) { return; } + SteamAuthSessionInterface.EndAuthSession(steamId.Value); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/EosP2PEndpoint.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/EosP2PEndpoint.cs new file mode 100644 index 000000000..a11503b25 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/EosP2PEndpoint.cs @@ -0,0 +1,32 @@ +#nullable enable + + +namespace Barotrauma.Networking; + +sealed class EosP2PEndpoint : P2PEndpoint +{ + public EosInterface.ProductUserId ProductUserId => new EosInterface.ProductUserId((Address as EosP2PAddress)!.EosStringRepresentation); + + public EosP2PEndpoint(EosInterface.ProductUserId puid) : this(new EosP2PAddress(puid.Value)) { } + + public EosP2PEndpoint(EosP2PAddress address) : base(address) { } + + public override string StringRepresentation => (Address as EosP2PAddress)!.StringRepresentation; + + public override LocalizedString ServerTypeString { get; } = TextManager.Get("PlayerHostedServer"); + + public override int GetHashCode() + => (Address as EosP2PAddress)!.GetHashCode(); + + public override bool Equals(object? obj) + => obj is EosP2PEndpoint otherEndpoint + && ProductUserId == otherEndpoint.ProductUserId; + + public new static Option Parse(string endpointStr) + => EosP2PAddress.Parse(endpointStr).Select(eosAddress => new EosP2PEndpoint(eosAddress)); + + public const string SocketName = "Barotrauma.EosP2PSocket"; + + public override P2PConnection MakeConnectionFromEndpoint() + => new EosP2PConnection(this); +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/LidgrenEndpoint.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/LidgrenEndpoint.cs index 6fc7a7c8d..8b50a608c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/LidgrenEndpoint.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/LidgrenEndpoint.cs @@ -60,7 +60,7 @@ namespace Barotrauma.Networking => NetEndpoint.GetHashCode(); public static bool operator ==(LidgrenEndpoint a, LidgrenEndpoint b) - => a.Address.Equals(b.Address) && a.Port == b.Port; + => a.NetEndpoint.EquivalentTo(b.NetEndpoint); public static bool operator !=(LidgrenEndpoint a, LidgrenEndpoint b) => !(a == b); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/P2PEndpoint.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/P2PEndpoint.cs new file mode 100644 index 000000000..9955ceab9 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/P2PEndpoint.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace Barotrauma.Networking; + +abstract class P2PEndpoint : Endpoint +{ + protected P2PEndpoint(P2PAddress address) : base(address) { } + + public abstract P2PConnection MakeConnectionFromEndpoint(); + + public new static Option Parse(string str) + => Endpoint.Parse(str).Bind(ep => ep is P2PEndpoint pep ? Option.Some(pep) : Option.None); +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/SteamP2PEndpoint.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/SteamP2PEndpoint.cs index 93c6fd1da..85169f1bf 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/SteamP2PEndpoint.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Endpoint/SteamP2PEndpoint.cs @@ -2,36 +2,27 @@ namespace Barotrauma.Networking { - sealed class SteamP2PEndpoint : Endpoint + sealed class SteamP2PEndpoint : P2PEndpoint { - public readonly SteamId SteamId; + public SteamId SteamId => (Address as SteamP2PAddress)!.SteamId; public override string StringRepresentation => SteamId.StringRepresentation; - public override LocalizedString ServerTypeString { get; } = TextManager.Get("SteamP2PServer"); + public override LocalizedString ServerTypeString { get; } = TextManager.Get("PlayerHostedServer"); - public SteamP2PEndpoint(SteamId steamId) : base(new SteamP2PAddress(steamId)) - { - SteamId = steamId; - } - - public new static Option Parse(string endpointStr) - => SteamId.Parse(endpointStr).Select(steamId => new SteamP2PEndpoint(steamId)); - - public override bool Equals(object? obj) - => obj switch - { - SteamP2PEndpoint otherEndpoint => this == otherEndpoint, - _ => false - }; + public SteamP2PEndpoint(SteamId steamId) : base(new SteamP2PAddress(steamId)) { } public override int GetHashCode() => SteamId.GetHashCode(); - public static bool operator ==(SteamP2PEndpoint a, SteamP2PEndpoint b) - => a.SteamId == b.SteamId; + public override bool Equals(object? obj) + => obj is SteamP2PEndpoint otherEndpoint + && this.SteamId == otherEndpoint.SteamId; - public static bool operator !=(SteamP2PEndpoint a, SteamP2PEndpoint b) - => !(a == b); + public new static Option Parse(string endpointStr) + => SteamId.Parse(endpointStr).Select(steamId => new SteamP2PEndpoint(steamId)); + + public override P2PConnection MakeConnectionFromEndpoint() + => new SteamP2PConnection(this); } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageDefragmenter.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageDefragmenter.cs new file mode 100644 index 000000000..b9d0ef514 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageDefragmenter.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Barotrauma.Networking; + +sealed class MessageDefragmenter +{ + private readonly Dictionary partialMessages = new Dictionary(); + + public Option> ProcessIncomingFragment(MessageFragment fragment) + { + if (!partialMessages.ContainsKey(fragment.FragmentId.MessageId)) + { + partialMessages[fragment.FragmentId.MessageId] = new MessageFragment[fragment.FragmentId.FragmentCount]; + } + else if (partialMessages[fragment.FragmentId.MessageId].Length != fragment.FragmentId.FragmentCount) + { + DebugConsole.AddWarning($"Got a fragment for message {fragment.FragmentId.MessageId} " + + $"with a mismatched expected fragment count"); + return Option.None; + } + + var fragmentBuffer = partialMessages[fragment.FragmentId.MessageId]; + if (fragment.FragmentId.FragmentIndex >= fragmentBuffer.Length) + { + DebugConsole.AddWarning($"Got a fragment for message {fragment.FragmentId.MessageId} " + + $"with an index greater than or equal to the expected fragment count ({fragment.FragmentId.FragmentIndex} >= {fragmentBuffer.Length})"); + return Option.None; + } + + fragmentBuffer[fragment.FragmentId.FragmentIndex] = fragment; + if (fragmentBuffer.All(f => !f.Data.IsDefault && f.FragmentId.MessageId == fragment.FragmentId.MessageId)) + { + partialMessages.Remove(fragment.FragmentId.MessageId); + return Option.Some(fragmentBuffer.SelectMany(f => f.Data).ToImmutableArray()); + } + return Option.None; + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragment.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragment.cs new file mode 100644 index 000000000..d4ccf9a2e --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragment.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; + +namespace Barotrauma.Networking; + +[NetworkSerialize(ArrayMaxSize = MaxSize)] +readonly record struct MessageFragment( + MessageFragment.Id FragmentId, + ImmutableArray Data) : INetSerializableStruct +{ + public const int MaxSize = 1100; + + [NetworkSerialize] + public readonly record struct Id( + ushort FragmentIndex, + ushort FragmentCount, + ushort MessageId) : INetSerializableStruct; +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragmenter.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragmenter.cs new file mode 100644 index 000000000..011b500e4 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Fragmentation/MessageFragmenter.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Barotrauma.Networking; + +sealed class MessageFragmenter +{ + private UInt16 nextFragmentedMessageId = 0; + private readonly List fragments = new List(); + + public ImmutableArray FragmentMessage(ReadOnlySpan bytes) + { + UInt16 msgId = nextFragmentedMessageId; + nextFragmentedMessageId++; + + int roundedByteCount = bytes.Length; + roundedByteCount += (MessageFragment.MaxSize - (roundedByteCount % MessageFragment.MaxSize)) % MessageFragment.MaxSize; + + int fragmentCount = roundedByteCount / MessageFragment.MaxSize; + fragments.Clear(); + fragments.EnsureCapacity(fragmentCount); + for (int i = 0; i < fragmentCount; i++) + { + var subset = bytes[(i * MessageFragment.MaxSize)..]; + if (subset.Length > MessageFragment.MaxSize) { subset = subset[..MessageFragment.MaxSize]; } + + fragments.Add(new MessageFragment( + FragmentId: new MessageFragment.Id( + FragmentIndex: (ushort)i, + FragmentCount: (ushort)fragmentCount, + MessageId: msgId), + Data: subset.ToArray().ToImmutableArray())); + } + + return fragments.ToImmutableArray(); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Message.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Message.cs index 87e0463a9..8ef2a28b8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Message.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Message/Message.cs @@ -10,7 +10,11 @@ namespace Barotrauma.Networking { public static class MsgConstants { - public const int MTU = 1200; //TODO: determine dynamically + // MTU currently set to the upper limit of what EOS P2P can do + // TODO: determine dynamically so other protocols can use a larger MTU, + // as well as handle a client with a lower MTU set outside of our control + public const int MTU = 1170; + public const int CompressionThreshold = 1000; public const int InitialBufferSize = 256; public const int BufferOverAllocateAmount = 4; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/EosP2PConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/EosP2PConnection.cs new file mode 100644 index 000000000..41aa5a2ca --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/EosP2PConnection.cs @@ -0,0 +1,8 @@ +#nullable enable + +namespace Barotrauma.Networking; + +sealed class EosP2PConnection : P2PConnection +{ + public EosP2PConnection(EosP2PEndpoint endpoint) : base(endpoint) { } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/LidgrenConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/LidgrenConnection.cs index 397f4836c..8f665e043 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/LidgrenConnection.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/LidgrenConnection.cs @@ -2,7 +2,7 @@ namespace Barotrauma.Networking { - sealed class LidgrenConnection : NetworkConnection + sealed class LidgrenConnection : NetworkConnection { public readonly NetConnection NetConnection; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/NetworkConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/NetworkConnection.cs index e1a46e55d..9c6e1928c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/NetworkConnection.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/NetworkConnection.cs @@ -8,6 +8,13 @@ namespace Barotrauma.Networking Disconnected = 0x2 } + abstract class NetworkConnection : NetworkConnection where T : Endpoint + { + protected NetworkConnection(T endpoint) : base(endpoint) { } + + public new T Endpoint => (base.Endpoint as T)!; + } + abstract class NetworkConnection { public const double TimeoutThreshold = 60.0; //full minute for timeout because loading screens can take quite a while @@ -23,7 +30,7 @@ namespace Barotrauma.Networking get; set; } - public NetworkConnection(Endpoint endpoint) + protected NetworkConnection(Endpoint endpoint) { Endpoint = endpoint; } @@ -35,9 +42,9 @@ namespace Barotrauma.Networking public void SetAccountInfo(AccountInfo newInfo) { - AccountInfo = newInfo; + if (AccountInfo.IsNone) { AccountInfo = newInfo; } } - + public sealed override string ToString() => Endpoint.StringRepresentation; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/P2PConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/P2PConnection.cs new file mode 100644 index 000000000..790ac90b7 --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/P2PConnection.cs @@ -0,0 +1,30 @@ +#nullable enable + +namespace Barotrauma.Networking; + +abstract class P2PConnection : P2PConnection where T : P2PEndpoint +{ + protected P2PConnection(T endpoint) : base(endpoint) { } + + public new T Endpoint => (base.Endpoint as T)!; +} + +abstract class P2PConnection : NetworkConnection +{ + protected P2PConnection(P2PEndpoint endpoint) : base(endpoint) + { + Heartbeat(); + } + + public double Timeout = 0.0; + + public void Decay(float deltaTime) + { + Timeout -= deltaTime; + } + + public void Heartbeat() + { + Timeout = TimeoutThreshold; + } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/PipeConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/PipeConnection.cs index bae9ac6e8..a18081a4d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/PipeConnection.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/PipeConnection.cs @@ -23,11 +23,11 @@ namespace Barotrauma.Networking => !(a == b); } - sealed class PipeConnection : NetworkConnection + sealed class PipeConnection : NetworkConnection { - public PipeConnection(AccountId accountId) : base(new PipeEndpoint()) + public PipeConnection(Option accountId) : base(new PipeEndpoint()) { - SetAccountInfo(new AccountInfo(Option.Some(accountId))); + SetAccountInfo(new AccountInfo(accountId)); } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/SteamP2PConnection.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/SteamP2PConnection.cs index 425e1bfd4..c01e7f5c1 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/SteamP2PConnection.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkConnection/SteamP2PConnection.cs @@ -1,24 +1,9 @@ namespace Barotrauma.Networking { - sealed class SteamP2PConnection : NetworkConnection + sealed class SteamP2PConnection : P2PConnection { - public double Timeout = 0.0; - public SteamP2PConnection(SteamId steamId) : this(new SteamP2PEndpoint(steamId)) { } - public SteamP2PConnection(SteamP2PEndpoint endpoint) : base(endpoint) - { - Heartbeat(); - } - - public void Decay(float deltaTime) - { - Timeout -= deltaTime; - } - - public void Heartbeat() - { - Timeout = TimeoutThreshold; - } + public SteamP2PConnection(SteamP2PEndpoint endpoint) : base(endpoint) { } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkExtensions.cs index 40c0e92b1..da74d80f2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkExtensions.cs @@ -52,8 +52,7 @@ namespace Barotrauma.Networking deliveryMethod switch { DeliveryMethod.Unreliable => NetDeliveryMethod.Unreliable, - DeliveryMethod.Reliable => NetDeliveryMethod.ReliableUnordered, - DeliveryMethod.ReliableOrdered => NetDeliveryMethod.ReliableOrdered, + DeliveryMethod.Reliable => NetDeliveryMethod.ReliableOrdered, _ => NetDeliveryMethod.Unreliable }; @@ -61,7 +60,6 @@ namespace Barotrauma.Networking deliveryMethod switch { DeliveryMethod.Reliable => Steamworks.P2PSend.Reliable, - DeliveryMethod.ReliableOrdered => Steamworks.P2PSend.Reliable, _ => Steamworks.P2PSend.Unreliable }; } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkPeerStructs.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkPeerStructs.cs index c8042a945..2ba983261 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkPeerStructs.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkPeerStructs.cs @@ -25,34 +25,42 @@ namespace Barotrauma.Networking } [NetworkSerialize(ArrayMaxSize = ushort.MaxValue)] - internal struct ClientSteamTicketAndVersionPacket : INetSerializableStruct + internal struct ClientAuthTicketAndVersionPacket : INetSerializableStruct { public string Name; public Option OwnerKey; - - #warning TODO: do something about the type of this - // It probably should be Option but we shouldn't build support for - // writing SteamIDs to INetSerializableStruct; we should consider adding - // attributes to give custom behaviors to specific members of a struct - public Option SteamId; - - public Option SteamAuthTicket; + public Option AccountId; + public Option AuthTicket; public string GameVersion; public Identifier Language; } [NetworkSerialize] - internal struct SteamP2PInitializationRelayPacket : INetSerializableStruct + internal readonly record struct P2POwnerToServerHeader + (string? EndpointStr, AccountInfo AccountInfo) : INetSerializableStruct + { + public Option Endpoint => P2PEndpoint.Parse(EndpointStr ?? ""); + } + + [NetworkSerialize] + internal readonly record struct P2PServerToOwnerHeader + (string? EndpointStr) : INetSerializableStruct + { + public Option Endpoint => P2PEndpoint.Parse(EndpointStr ?? ""); + } + + [NetworkSerialize] + internal struct P2PInitializationRelayPacket : INetSerializableStruct { public ulong LobbyID; public PeerPacketMessage Message; } [NetworkSerialize] - internal struct SteamP2PInitializationOwnerPacket : INetSerializableStruct - { - public string OwnerName; - } + internal readonly record struct P2PInitializationOwnerPacket( + string Name, + AccountId AccountId) + : INetSerializableStruct; [NetworkSerialize(ArrayMaxSize = ushort.MaxValue)] @@ -68,7 +76,7 @@ namespace Barotrauma.Networking public byte[] Buffer; public readonly int Length => Buffer.Length; - public readonly IReadMessage GetReadMessageUncompressed() => new ReadWriteMessage(Buffer, 0, Length, copyBuf: false); + public readonly IReadMessage GetReadMessageUncompressed() => new ReadWriteMessage(Buffer, 0, Length * 8, copyBuf: false); public readonly IReadMessage GetReadMessage(bool isCompressed, NetworkConnection conn) => new ReadOnlyMessage(Buffer, isCompressed, 0, Length, conn); } @@ -140,7 +148,8 @@ namespace Barotrauma.Networking DisconnectReason.ExcessiveDesyncOldEvent => ServerMessage, DisconnectReason.ExcessiveDesyncRemovedEvent => ServerMessage, DisconnectReason.SyncTimeout => ServerMessage, - _ => TextManager.Get($"DisconnectReason.{DisconnectReason}").Fallback(TextManager.Get("ConnectionLost")) + DisconnectReason.AuthenticationFailed => TextManager.Get($"DisconnectReason.{DisconnectReason}").Fallback(TextManager.Get("ChatMsg.DisconnectReason.AuthenticationRequired")), + _ => TextManager.Get($"DisconnectReason.{DisconnectReason}").Fallback($"{TextManager.Get("ConnectionLost")} ({DisconnectReason})") }; public LocalizedString ReconnectMessage @@ -178,15 +187,12 @@ namespace Barotrauma.Networking or DisconnectReason.TooManyFailedLogins or DisconnectReason.InvalidVersion); - private const string lidgrenSeparator = ":hankey:"; - /// - /// This exists because Lidgren is a piece of shit and - /// doesn't readily support sending anything other than - /// a string through a disconnect packet, so this thing - /// needs a sufficiently nasty string representation that - /// can be decoded with some certainty that it won't get - /// mangled by user input. + /// This exists because Lidgren doesn't readily support + /// sending anything other than a string through a disconnect + /// packet, so this thing needs a sufficiently nasty string + /// representation that can be decoded with some certainty + /// that it won't get mangled by user input. /// public string ToLidgrenStringRepresentation() { @@ -194,7 +200,7 @@ namespace Barotrauma.Networking => Convert.ToBase64String(Encoding.UTF8.GetBytes(str)); return DisconnectReason - + lidgrenSeparator + + NetworkMagicStrings.LidgrenDisconnectSeparator + strToBase64(AdditionalInformation); } @@ -209,16 +215,16 @@ namespace Barotrauma.Networking case Lidgren.Network.NetConnection.NoResponseMessage: case "Connection timed out": case "Reconnecting": - return Option.Some(WithReason(DisconnectReason.Timeout)); + return Option.Some(WithReason(DisconnectReason.Timeout)); } static string base64ToStr(string base64) => Encoding.UTF8.GetString(Convert.FromBase64String(base64)); - string[] split = str.Split(lidgrenSeparator); - if (split.Length != 2) { return Option.None(); } - if (!Enum.TryParse(split[0], out DisconnectReason disconnectReason)) { return Option.None(); } - return Option.Some(new PeerDisconnectPacket(disconnectReason, base64ToStr(split[1]))); + string[] split = str.Split(NetworkMagicStrings.LidgrenDisconnectSeparator); + if (split.Length != 2) { return Option.None; } + if (!Enum.TryParse(split[0], out DisconnectReason disconnectReason)) { return Option.None; } + return Option.Some(new PeerDisconnectPacket(disconnectReason, base64ToStr(split[1]))); } public static PeerDisconnectPacket Custom(string customMessage) @@ -247,12 +253,12 @@ namespace Barotrauma.Networking public static PeerDisconnectPacket SteamAuthError(Steamworks.BeginAuthResult error) => new PeerDisconnectPacket( - DisconnectReason.SteamAuthenticationFailed, + DisconnectReason.AuthenticationFailed, $"{nameof(Steamworks.BeginAuthResult)}.{error}"); public static PeerDisconnectPacket SteamAuthError(Steamworks.AuthResponse error) => new PeerDisconnectPacket( - DisconnectReason.SteamAuthenticationFailed, + DisconnectReason.AuthenticationFailed, $"{nameof(Steamworks.AuthResponse)}.{error}"); } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerListContentPackageInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerListContentPackageInfo.cs new file mode 100644 index 000000000..a796a9c4b --- /dev/null +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerListContentPackageInfo.cs @@ -0,0 +1,23 @@ +namespace Barotrauma.Networking; + +public readonly record struct ServerListContentPackageInfo( + string Name, string Hash, Option Id) +{ + public ServerListContentPackageInfo(ContentPackage pkg) + : this(pkg.Name, pkg.Hash.StringRepresentation, pkg.UgcId) {} + + public static Option ParseSingleEntry(string singleEntry) + { + if (singleEntry.SplitEscaped(',') is not { Count: 3 } split) { return Option.None; } + + return Option.Some( + new ServerListContentPackageInfo( + split[0], + split[1], + ContentPackageId.Parse(split[2]))); + } + + public override string ToString() + => new[] { Name, Hash, Id.Select(id => id.StringRepresentation).Fallback("") } + .JoinEscaped(','); +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs index c105f27de..d890f70ca 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Networking/ServerSettings.cs @@ -4,6 +4,7 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -294,7 +295,7 @@ namespace Barotrauma.Networking if (typeName != null || property.PropertyType.IsEnum) { NetPropertyData netPropertyData = new NetPropertyData(this, property, typeName); - UInt32 key = ToolBox.IdentifierToUint32Hash(netPropertyData.Name, md5); + UInt32 key = ToolBoxCore.IdentifierToUint32Hash(netPropertyData.Name, md5); if (key == 0) { key++; } //0 is reserved to indicate the end of the netproperties section of a message if (netProperties.ContainsKey(key)){ throw new Exception("Hashing collision in ServerSettings.netProperties: " + netProperties[key] + " has same key as " + property.Name + " (" + key.ToString() + ")"); } netProperties.Add(key, netPropertyData); @@ -311,7 +312,7 @@ namespace Barotrauma.Networking if (typeName != null || property.PropertyType.IsEnum) { NetPropertyData netPropertyData = new NetPropertyData(networkMember.KarmaManager, property, typeName); - UInt32 key = ToolBox.IdentifierToUint32Hash(netPropertyData.Name, md5); + UInt32 key = ToolBoxCore.IdentifierToUint32Hash(netPropertyData.Name, md5); if (netProperties.ContainsKey(key)) { throw new Exception("Hashing collision in ServerSettings.netProperties: " + netProperties[key] + " has same key as " + property.Name + " (" + key.ToString() + ")"); } netProperties.Add(key, netPropertyData); } @@ -1136,5 +1137,47 @@ namespace Barotrauma.Networking msg.WriteUInt16((UInt16)subList.FindIndex(s => s.Name.Equals(submarineName, StringComparison.OrdinalIgnoreCase))); } } + + public void UpdateServerListInfo(Action setter) + { + void set(string key, object obj) => setter(key.ToIdentifier(), obj); + + set("ServerName", ServerName); + set("MaxPlayers", MaxPlayers); + set("HasPassword", HasPassword); + set("message", ServerMessageText); + set("version", GameMain.Version); + set("playercount", GameMain.NetworkMember.ConnectedClients.Count); + set("contentpackages", ContentPackageManager.EnabledPackages.All.Where(p => p.HasMultiplayerSyncedContent)); + set("modeselectionmode", ModeSelectionMode); + set("subselectionmode", SubSelectionMode); + set("voicechatenabled", VoiceChatEnabled); + set("allowspectating", AllowSpectating); + set("allowrespawn", AllowRespawn); + set("traitors", TraitorProbability.ToString(CultureInfo.InvariantCulture)); + set("friendlyfireenabled", AllowFriendlyFire); + set("karmaenabled", KarmaEnabled); + set("gamestarted", GameMain.NetworkMember.GameStarted); + set("gamemode", GameModeIdentifier); + set("playstyle", PlayStyle); + set("language", Language.ToString()); +#if SERVER + set("eoscrossplay", EosInterface.Core.IsInitialized); +#else + set("eoscrossplay", EosInterface.IdQueries.IsLoggedIntoEosConnect || Eos.EosSessionManager.CurrentOwnedSession.IsSome()); +#endif + if (GameMain.NetLobbyScreen?.SelectedSub != null) + { + set("submarine", GameMain.NetLobbyScreen.SelectedSub.Name); + } + if (Steamworks.SteamClient.IsLoggedOn) + { + string pingLocation = Steamworks.SteamNetworkingUtils.LocalPingLocation?.ToString(); + if (!pingLocation.IsNullOrEmpty()) + { + set("steampinglocation", pingLocation); + } + } + } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Prefabs/PrefabCollection.cs b/Barotrauma/BarotraumaShared/SharedSource/Prefabs/PrefabCollection.cs index e11ea36db..cef4c5681 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Prefabs/PrefabCollection.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Prefabs/PrefabCollection.cs @@ -386,7 +386,7 @@ namespace Barotrauma { using (MD5 md5 = MD5.Create()) { - prefabWithUintIdentifier.UintIdentifier = ToolBox.IdentifierToUint32Hash(prefab.Identifier, md5); + prefabWithUintIdentifier.UintIdentifier = ToolBoxCore.IdentifierToUint32Hash(prefab.Identifier, md5); //it's theoretically possible for two different values to generate the same hash, but the probability is astronomically small T? findCollision() diff --git a/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs index d6d5d5887..b21c9327e 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Serialization/XMLExtensions.cs @@ -5,10 +5,12 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Xml; using System.Xml.Linq; +using Barotrauma.Extensions; using File = Barotrauma.IO.File; using FileStream = Barotrauma.IO.FileStream; using Path = Barotrauma.IO.Path; @@ -841,6 +843,13 @@ namespace Barotrauma return vector; } + private static readonly ImmutableDictionary monoGameColors = + typeof(Color) + .GetProperties(BindingFlags.Static | BindingFlags.Public) + .Where(p => p.PropertyType == typeof(Color)) + .Select(p => (p.Name.ToIdentifier(), p.GetValueFromStaticProperty())) + .ToImmutableDictionary(); + public static Color ParseColor(string stringColor, bool errorMessages = true) { if (stringColor.StartsWith("gui.", StringComparison.OrdinalIgnoreCase)) @@ -864,6 +873,11 @@ namespace Barotrauma return Color.White; } + if (monoGameColors.TryGetValue(stringColor.ToIdentifier(), out var monoGameColor)) + { + return monoGameColor; + } + string[] strComponents = stringColor.Split(','); Color color = Color.White; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs b/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs index 0ad15815d..12cad8921 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Settings/GameSettings.cs @@ -84,6 +84,7 @@ namespace Barotrauma Graphics = GraphicsSettings.GetDefault(), Audio = AudioSettings.GetDefault(), #if CLIENT + CrossplayChoice = Eos.EosSteamPrimaryLogin.CrossplayChoice.Unknown, DisableGlobalSpamList = false, KeyMap = KeyMapping.GetDefault(), InventoryKeyMap = InventoryKeyMapping.GetDefault() @@ -91,9 +92,7 @@ namespace Barotrauma }; #if DEBUG - config.UseSteamMatchmaking = true; config.QuickStartSub = "Humpback".ToIdentifier(); - config.RequireSteamAuthentication = true; config.AutomaticQuickStartEnabled = false; config.AutomaticCampaignLoadEnabled = false; config.TextManagerDebugModeEnabled = false; @@ -156,20 +155,16 @@ namespace Barotrauma public Identifier QuickStartSub; public string RemoteMainMenuContentUrl; #if CLIENT + public Eos.EosSteamPrimaryLogin.CrossplayChoice CrossplayChoice; public XElement SavedCampaignSettings; public bool DisableGlobalSpamList; #endif #if DEBUG - public bool UseSteamMatchmaking; - public bool RequireSteamAuthentication; public bool AutomaticQuickStartEnabled; public bool AutomaticCampaignLoadEnabled; public bool TestScreenEnabled; public bool TextManagerDebugModeEnabled; public bool ModBreakerMode; -#else - public bool UseSteamMatchmaking => true; - public bool RequireSteamAuthentication => true; #endif public struct GraphicsSettings diff --git a/Barotrauma/BarotraumaShared/SharedSource/Steam/AuthTicket.cs b/Barotrauma/BarotraumaShared/SharedSource/Steam/AuthTicket.cs index a68b959dd..a41889ad4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Steam/AuthTicket.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Steam/AuthTicket.cs @@ -7,19 +7,20 @@ namespace Barotrauma.Steam { static partial class SteamManager { - private static Option currentMultiplayerTicket = Option.None; - public static Option GetAuthSessionTicketForMultiplayer(Endpoint remoteHostEndpoint) + #region Auth ticket for Steam host + private static Option currentSteamHostAuthTicket = Option.None; + public static Option GetAuthSessionTicketForSteamHost(Endpoint remoteHostEndpoint) { if (!IsInitialized) { return Option.None; } - if (currentMultiplayerTicket.TryUnwrap(out var ticketToCancel)) + if (currentSteamHostAuthTicket.TryUnwrap(out var ticketToCancel)) { ticketToCancel.Cancel(); } - currentMultiplayerTicket = Option.None; + currentSteamHostAuthTicket = Option.None; var netIdentity = remoteHostEndpoint switch { @@ -32,13 +33,42 @@ namespace Barotrauma.Steam }; var newTicket = Steamworks.SteamUser.GetAuthSessionTicket(netIdentity); - currentMultiplayerTicket = newTicket != null + currentSteamHostAuthTicket = newTicket != null ? Option.Some(newTicket) : Option.None; - return currentMultiplayerTicket; + return currentSteamHostAuthTicket; } + #endregion Auth ticket for Steam host + #region Auth ticket for EOS host + private const string EosHostAuthIdentity = "BarotraumaRemotePlayerAuth"; + + private static Option currentEosHostAuthTicket = Option.None; + public static async Task> GetAuthTicketForEosHostAuth() + { + if (!IsInitialized) + { + return Option.None; + } + + if (currentEosHostAuthTicket.TryUnwrap(out var ticketToCancel)) + { + ticketToCancel.Cancel(); + } + currentEosHostAuthTicket = Option.None; + + var newTicket = await Steamworks.SteamUser.GetAuthTicketForWebApi(identity: EosHostAuthIdentity); + + currentEosHostAuthTicket = newTicket != null + ? Option.Some(newTicket) + : Option.None; + + return currentEosHostAuthTicket; + } + #endregion Auth ticket for EOS host + + #region Auth ticket for GameAnalytics consent server private const string GameAnalyticsConsentIdentity = "BarotraumaGameAnalyticsConsent"; private static Option currentGameAnalyticsConsentTicket = Option.None; @@ -63,5 +93,6 @@ namespace Barotrauma.Steam return currentGameAnalyticsConsentTicket; } + #endregion Auth ticket for GameAnalytics consent server } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Steam/SteamManager.cs b/Barotrauma/BarotraumaShared/SharedSource/Steam/SteamManager.cs index 5de759e46..6467d4e54 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Steam/SteamManager.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Steam/SteamManager.cs @@ -1,8 +1,10 @@ -using Steamworks.Data; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Runtime.InteropServices; using Barotrauma.Networking; +using Barotrauma.IO; namespace Barotrauma.Steam { @@ -38,6 +40,15 @@ namespace Barotrauma.Steam } } + public static bool SteamworksLibExists + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? File.Exists("steam_api64.dll") + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? File.Exists("libsteam_api64.dylib") + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ? File.Exists("libsteam_api64.so") + : false; + public static void Initialize() { InitializeProjectSpecific(); @@ -53,6 +64,16 @@ namespace Barotrauma.Steam return Option.Some(new SteamId(Steamworks.SteamClient.SteamId)); } + public static Option GetOwnerSteamId() + { + if (!IsInitialized || !Steamworks.SteamClient.IsValid) + { + return Option.None(); + } + + return Option.Some(new SteamId(Steamworks.SteamClient.SteamId)); + } + public static bool IsFamilyShared() { if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return false; } @@ -112,42 +133,78 @@ namespace Barotrauma.Steam return unlocked; } - public static bool IncrementStat(Identifier statName, int increment) + /// + /// Increment multiple stats in bulk. + /// Make sure to call StoreStats() after calling this method since it doesn't do it automatically. + /// + /// + public static void IncrementStats(params (AchievementStat Identifier, float Increment)[] stats) + => Array.ForEach(stats, static s + => IncrementStat(s.Identifier, s.Increment, storeStats: false)); + + public static bool IncrementStat(AchievementStat statName, int increment, bool storeStats = true) { if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return false; } DebugConsole.Log($"Incremented stat \"{statName}\" by " + increment); - bool success = Steamworks.SteamUserStats.AddStat(statName.Value.ToLowerInvariant(), increment); + bool success = Steamworks.SteamUserStats.AddStatInt(statName.ToIdentifier().Value.ToLowerInvariant(), increment); if (!success) { DebugConsole.Log("Failed to increment stat \"" + statName + "\"."); } - else + else if (storeStats) { StoreStats(); } return success; } - public static bool IncrementStat(Identifier statName, float increment) + public static bool IncrementStat(AchievementStat statName, float increment, bool storeStats = true) { if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return false; } DebugConsole.Log($"Incremented stat \"{statName}\" by " + increment); - bool success = Steamworks.SteamUserStats.AddStat(statName.Value.ToLowerInvariant(), increment); + bool success = Steamworks.SteamUserStats.AddStatFloat(statName.ToIdentifier().Value.ToLowerInvariant(), increment); if (!success) { DebugConsole.Log("Failed to increment stat \"" + statName + "\"."); } - else + else if (storeStats) { StoreStats(); } return success; } - public static int GetStatInt(Identifier statName) + public static int GetStatInt(AchievementStat stat) { if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return 0; } - return Steamworks.SteamUserStats.GetStatInt(statName.Value.ToLowerInvariant()); + return Steamworks.SteamUserStats.GetStatInt(stat.ToString().ToLowerInvariant()); + } + + public static float GetStatFloat(AchievementStat stat) + { + if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return 0f; } + return Steamworks.SteamUserStats.GetStatFloat(stat.ToString().ToLowerInvariant()); + } + + public static ImmutableDictionary GetAllStats() + { + if (!IsInitialized || !Steamworks.SteamClient.IsValid) { return ImmutableDictionary.Empty; } + + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (AchievementStat stat in AchievementStatExtension.SteamStats) + { + if (stat.IsFloatStat()) + { + builder.Add(stat, GetStatFloat(stat)); + } + else + { + builder.Add(stat, GetStatInt(stat)); + } + } + + return builder.ToImmutable(); } public static bool StoreStats() @@ -177,7 +234,7 @@ namespace Barotrauma.Steam { //this should be run even if SteamManager is uninitialized //servers need to be able to notify clients of unlocked talents even if the server isn't connected to Steam - SteamAchievementManager.Update(deltaTime); + AchievementManager.Update(deltaTime); if (!IsInitialized) { return; } @@ -193,22 +250,6 @@ namespace Barotrauma.Steam if (Steamworks.SteamServer.IsValid) { Steamworks.SteamServer.Shutdown(); } } - public static IEnumerable ParseWorkshopIds(string workshopIdData) - { - string[] workshopIds = workshopIdData.Split(','); - foreach (string id in workshopIds) - { - if (ulong.TryParse(id, out ulong idCast)) - { - yield return idCast; - } - else - { - yield return 0; - } - } - } - public static IEnumerable WorkshopUrlsToIds(IEnumerable urls) { return urls.Select((u) => diff --git a/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs b/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs index a832a5abe..ce4225cc2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs @@ -140,7 +140,7 @@ namespace Barotrauma.Steam return await queryTask; } - public static async Task MakeRequest(UInt64 id) + public static async Task> MakeRequest(UInt64 id) { Task ourTask; lock (mutex) @@ -155,7 +155,7 @@ namespace Barotrauma.Steam } var items = await ourTask; - var result = items.FirstOrNull(it => it.Id == id); + var result = items.FirstOrNone(it => it.Id == id); return result; } } @@ -165,7 +165,7 @@ namespace Barotrauma.Steam /// The description of the returned item is truncated to save bandwidth. /// /// Workshop Item ID - public static Task GetItem(UInt64 itemId) + public static Task> GetItem(UInt64 itemId) => SingleItemRequestPool.MakeRequest(itemId); /// @@ -176,15 +176,17 @@ namespace Barotrauma.Steam /// /// If true, ask for the item's entire description, otherwise it'll be truncated. /// - public static async Task GetItemAsap(UInt64 itemId, bool withLongDescription = false) + public static async Task> GetItemAsap(UInt64 itemId, bool withLongDescription = false) { - if (!IsInitialized) { return null; } + if (!IsInitialized) { return Option.None; } var items = await GetWorkshopItems( Steamworks.Ugc.Query.All .WithFileId(itemId) .WithLongDescription(withLongDescription)); - return items.Any() ? items.First() : null; + return items.Any() + ? Option.Some(items.First()) + : Option.None; } public static async Task ForceRedownload(UInt64 itemId) @@ -379,7 +381,7 @@ namespace Barotrauma.Steam // made private. Players cannot download updates for these, so // we treat them as if they were deleted. allItems = (await Task.WhenAll(allItems.Select(it => GetItem(it.Id.Value)))) - .NotNull() + .NotNone() .Where(it => it.ConsumerApp == AppID) .ToHashSet(); @@ -399,7 +401,7 @@ namespace Barotrauma.Steam TaskPool.Add("DeleteUnsubscribedMods", GetPublishedAndSubscribedItems().WaitForLoadingScreen(), t => { - if (!t.TryGetResult(out ISet items)) { return; } + if (!t.TryGetResult(out ISet? items)) { return; } var ids = items.Select(it => it.Id.Value).ToHashSet(); var toUninstall = ContentPackageManager.WorkshopPackages .Where(pkg @@ -428,8 +430,8 @@ namespace Barotrauma.Steam { using var installCounter = await InstallTaskCounter.Create(id); - var itemNullable = await GetItem(id); - if (!(itemNullable is { } item)) { return; } + var itemOption = await GetItem(id); + if (!itemOption.TryUnwrap(out var item)) { return; } await Task.Yield(); string itemTitle = item.Title?.Trim() ?? ""; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Text/LocalizedString/LocalizedString.cs b/Barotrauma/BarotraumaShared/SharedSource/Text/LocalizedString/LocalizedString.cs index 6ddc13c18..a34f3dff8 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Text/LocalizedString/LocalizedString.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Text/LocalizedString/LocalizedString.cs @@ -48,10 +48,23 @@ namespace Barotrauma public abstract void RetrieveValue(); - public static implicit operator LocalizedString(string value) => new RawLString(value); + public static readonly RawLString EmptyString = new RawLString(""); + public static implicit operator LocalizedString(string value) + => !value.IsNullOrEmpty() + ? new RawLString(value) + : EmptyString; public static implicit operator LocalizedString(char value) => new RawLString(value.ToString()); - public static LocalizedString operator+(LocalizedString left, LocalizedString right) => new ConcatLString(left, right); + public static LocalizedString operator+(LocalizedString left, LocalizedString right) + { + // If either side of the concatenation is an empty string, + // return the other string instead of creating a new object + if (left is RawLString { Value.Length: 0 }) { return right; } + if (right is RawLString { Value.Length: 0 }) { return left; } + + return new ConcatLString(left, right); + } + public static LocalizedString operator+(LocalizedString left, object right) => left + (right.ToString() ?? ""); public static LocalizedString operator+(object left, LocalizedString right) => (left.ToString() ?? "") + right; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/AssemblyInfo.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/AssemblyInfo.cs index 7a904fdac..22e4b96cd 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/AssemblyInfo.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/AssemblyInfo.cs @@ -1,11 +1,11 @@ -using System; -using System.Linq; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; [assembly:InternalsVisibleTo("WindowsTest"), InternalsVisibleTo("MacTest"), InternalsVisibleTo("LinuxTest")] + public static class AssemblyInfo { public static readonly string GitRevision; @@ -13,6 +13,38 @@ public static class AssemblyInfo public static readonly string ProjectDir; public static readonly string BuildString; + public enum Platform + { + Windows, + MacOS, + Linux + } + + public enum Configuration + { + Release, + Unstable, + Debug + } + +#if WINDOWS + public const Platform CurrentPlatform = Platform.Windows; +#elif OSX + public const Platform CurrentPlatform = Platform.MacOS; +#elif LINUX + public const Platform CurrentPlatform = Platform.Linux; +#else + #error Unknown platform +#endif + +#if DEBUG + public const Configuration CurrentConfiguration = Configuration.Debug; +#elif UNSTABLE + public const Configuration CurrentConfiguration = Configuration.Unstable; +#else + public const Configuration CurrentConfiguration = Configuration.Release; +#endif + static AssemblyInfo() { var asm = typeof(AssemblyInfo).Assembly; @@ -27,22 +59,7 @@ public static class AssemblyInfo string[] dirSplit = ProjectDir.Split('/', '\\'); ProjectDir = string.Join(ProjectDir.Contains('/') ? '/' : '\\', dirSplit.Take(dirSplit.Length - 2)); - BuildString = "Unknown"; -#if WINDOWS - BuildString = "Windows"; -#elif OSX - BuildString = "Mac"; -#elif LINUX - BuildString = "Linux"; -#endif - -#if DEBUG - BuildString = "Debug" + BuildString; -#elif UNSTABLE - BuildString = "Unstable" + BuildString; -#else - BuildString = "Release" + BuildString; -#endif + BuildString = $"{CurrentConfiguration}{CurrentPlatform}"; } public static string CleanupStackTrace(this string stackTrace) diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Md5Hash.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/Md5Hash.cs index fbfe75287..c672b5745 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Md5Hash.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/Md5Hash.cs @@ -44,26 +44,10 @@ namespace Barotrauma } private static string ByteRepresentationToStringRepresentation(byte[] byteHash) - { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < byteHash.Length; i++) - { - sb.Append(byteHash[i].ToString("X2")); - } - - return sb.ToString(); - } + => ToolBoxCore.ByteArrayToHexString(byteHash); private static byte[] StringRepresentationToByteRepresentation(string strHash) - { - var byteRepresentation = new byte[strHash.Length / 2]; - for (int i = 0; i < byteRepresentation.Length; i++) - { - byteRepresentation[i] = Convert.ToByte(strHash.Substring(i * 2, 2), 16); - } - - return byteRepresentation; - } + => ToolBoxCore.HexStringToByteArray(strHash); public static string GetShortHash(string fullHash) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs index 3e739b555..8bda617ae 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/Rand.cs @@ -1,4 +1,5 @@ -using Microsoft.Xna.Framework; +using Barotrauma.Extensions; +using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -30,6 +31,7 @@ namespace Barotrauma public static Random GetRNG(RandSync randSync) { + CheckRandThreadSafety(randSync); return randSync == RandSync.Unsynced ? localRandom : syncedRandom[randSync]; } @@ -70,16 +72,10 @@ namespace Barotrauma } public static float Range(float minimum, float maximum, RandSync sync=RandSync.Unsynced) - { - CheckRandThreadSafety(sync); - return (float)(sync == RandSync.Unsynced ? localRandom : (syncedRandom[sync])).NextDouble() * (maximum - minimum) + minimum; - } + => GetRNG(sync).Range(minimum, maximum); public static double Range(double minimum, double maximum, RandSync sync = RandSync.Unsynced) - { - CheckRandThreadSafety(sync); - return (sync == RandSync.Unsynced ? localRandom : (syncedRandom[sync])).NextDouble() * (maximum - minimum) + minimum; - } + => GetRNG(sync).Range(minimum, maximum); /// /// Min inclusive, Max exclusive! diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Result.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/Result.cs deleted file mode 100644 index 20e5f3290..000000000 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Result.cs +++ /dev/null @@ -1,85 +0,0 @@ -#nullable enable -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Barotrauma -{ - public abstract class Result - where T: notnull - where TError: notnull - { - public abstract bool IsSuccess { get; } - public bool IsFailure => !IsSuccess; - - public static Result Success(T value) - => new Success(value); - - public static Result Failure(TError error) - => new Failure(error); - - public abstract bool TryUnwrapSuccess([MaybeNullWhen(returnValue: false)] out T value); - public abstract bool TryUnwrapFailure([MaybeNullWhen(returnValue: false)] out TError value); - - public abstract override string? ToString(); - - public static (Func> Success, Func> Failure) GetFactoryMethods() - => (Success, Failure); - } - - public sealed class Success : Result - where T: notnull - where TError: notnull - { - public readonly T Value; - public override bool IsSuccess => true; - - public override bool TryUnwrapSuccess([MaybeNullWhen(returnValue: false)] out T value) - { - value = Value; - return true; - } - - public override bool TryUnwrapFailure([MaybeNullWhen(returnValue: false)] out TError value) - { - value = default; - return false; - } - - public override string ToString() - => $"Success<{typeof(T).NameWithGenerics()}, {typeof(TError).NameWithGenerics()}>({Value})"; - - public Success(T value) - { - Value = value; - } - } - - public sealed class Failure : Result - where T: notnull - where TError: notnull - { - public readonly TError Error; - - public override bool IsSuccess => false; - - public override bool TryUnwrapSuccess([MaybeNullWhen(returnValue: false)] out T value) - { - value = default; - return false; - } - - public override bool TryUnwrapFailure([MaybeNullWhen(returnValue: false)] out TError value) - { - value = Error; - return true; - } - - public override string ToString() - => $"Failure<{typeof(T).NameWithGenerics()}, {typeof(TError).NameWithGenerics()}>({Error})"; - - public Failure(TError error) - { - Error = error; - } - } -} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskExtensions.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskExtensions.cs index 022fcdb3f..236ed7e41 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskExtensions.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskExtensions.cs @@ -1,20 +1,10 @@ -using System.Threading.Tasks; +#nullable enable +using System.Threading.Tasks; namespace Barotrauma { - static class TaskExtensions + public static class TaskExtensions { - public static bool TryGetResult(this Task task, out T result) - { - if (task is Task { IsCompletedSuccessfully: true } castTask) - { - result = castTask.Result; - return true; - } - result = default; - return false; - } - public static async Task WaitForLoadingScreen(this Task task) { var result = await task; @@ -27,4 +17,4 @@ namespace Barotrauma return result; } } -} +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/ToolBox.cs b/Barotrauma/BarotraumaShared/SharedSource/Utils/ToolBox.cs index 9605361b0..ccea18bfa 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/ToolBox.cs +++ b/Barotrauma/BarotraumaShared/SharedSource/Utils/ToolBox.cs @@ -199,38 +199,6 @@ namespace Barotrauma return inputType; } - /// - /// Convert a HSV value into a RGB value. - /// - /// Value between 0 and 360 - /// Value between 0 and 1 - /// Value between 0 and 1 - /// Reference - /// - public static Color HSVToRGB(float hue, float saturation, float value) - { - float c = value * saturation; - - float h = Math.Clamp(hue, 0, 360) / 60f; - - float x = c * (1 - Math.Abs(h % 2 - 1)); - - float r = 0, - g = 0, - b = 0; - - if (0 <= h && h <= 1) { r = c; g = x; b = 0; } - else if (1 < h && h <= 2) { r = x; g = c; b = 0; } - else if (2 < h && h <= 3) { r = 0; g = c; b = x; } - else if (3 < h && h <= 4) { r = 0; g = x; b = c; } - else if (4 < h && h <= 5) { r = x; g = 0; b = c; } - else if (5 < h && h <= 6) { r = c; g = 0; b = x; } - - float m = value - c; - - return new Color(r + m, g + m, b + m); - } - /// /// Returns either a green [x] or a red [o] /// @@ -459,24 +427,6 @@ namespace Barotrauma return default; } - public static UInt32 IdentifierToUint32Hash(Identifier id, MD5 md5) - => StringToUInt32Hash(id.Value.ToLowerInvariant(), md5); - - public static UInt32 StringToUInt32Hash(string str, MD5 md5) - { - //calculate key based on MD5 hash instead of string.GetHashCode - //to ensure consistent results across platforms - byte[] inputBytes = Encoding.UTF8.GetBytes(str); - byte[] hash = md5.ComputeHash(inputBytes); - - UInt32 key = (UInt32)((str.Length & 0xff) << 24); //could use more of the hash here instead? - key |= (UInt32)(hash[hash.Length - 3] << 16); - key |= (UInt32)(hash[hash.Length - 2] << 8); - key |= (UInt32)(hash[hash.Length - 1]); - - return key; - } - /// /// Returns a new instance of the class with all properties and fields copied. /// @@ -542,14 +492,6 @@ namespace Barotrauma } } - public static string ByteArrayToString(byte[] ba) - { - StringBuilder hex = new StringBuilder(ba.Length * 2); - foreach (byte b in ba) - hex.AppendFormat("{0:x2}", b); - return hex.ToString(); - } - public static string EscapeCharacters(string str) { return str.Replace("\\", "\\\\").Replace("\"", "\\\""); @@ -692,13 +634,6 @@ namespace Barotrauma return new Rectangle(topLeft, size); } - public static Exception GetInnermost(this Exception e) - { - while (e.InnerException != null) { e = e.InnerException; } - - return e; - } - public static void ThrowIfNull([NotNull] T o) { if (o is null) { throw new ArgumentNullException(); } diff --git a/Barotrauma/BarotraumaShared/changelog.txt b/Barotrauma/BarotraumaShared/changelog.txt index e0b94102d..6c6ee9198 100644 --- a/Barotrauma/BarotraumaShared/changelog.txt +++ b/Barotrauma/BarotraumaShared/changelog.txt @@ -1,3 +1,17 @@ +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.3.0.1 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Fixed character face selection not working in the singleplayer campaign setup menu. +- Made the "connection lost" error messages when connecting to a server fails more descriptive. + +------------------------------------------------------------------------------------------------------------------------------------------------- +v1.3.0.0 +------------------------------------------------------------------------------------------------------------------------------------------------- + +- Epic Games Store release. +- Steam players have the option to enable crossplay between Steam and the Epic Store. When you launch Barotrauma for the first time following our EGS release, you will see a prompt asking whether you want to enable crossplay. If you do not wish to do so, you can decline, and that will be all. You can also update your preferences at any time in the game settings. + ------------------------------------------------------------------------------------------------------------------------------------------------- v1.2.8.0 ------------------------------------------------------------------------------------------------------------------------------------------------- diff --git a/Barotrauma/BarotraumaTest/SplitEscapedTest.cs b/Barotrauma/BarotraumaTest/SplitEscapedTest.cs new file mode 100644 index 000000000..ae2ab641f --- /dev/null +++ b/Barotrauma/BarotraumaTest/SplitEscapedTest.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Barotrauma; +using FluentAssertions; +using FsCheck; +using Xunit; + +namespace TestProject; + +public sealed class SplitEscapedTest +{ + private const char Joiner = ','; + private readonly record struct PathologicalString(string Value); + + private class CustomGenerators + { + public static Arbitrary SplittableStringGenerator() + { + var rng = new System.Random(); + return Arb.Generate() + .Where(s => s != null) + .Select(s => new PathologicalString( + // Generate a string that only contains backslashes and commas + string.Join("", s.Select(_ => rng.Next() % 100 < 50 ? '\\' : Joiner)))) + .ToArbitrary(); + } + } + + public SplitEscapedTest() + { + Arb.Register(); + } + + [Fact] + public void EqualityTest() + { + Prop.ForAll(EqualityCheck).QuickCheckThrowOnFailure(); + Prop.ForAll(EqualityCheck).QuickCheckThrowOnFailure(); + } + + private static void EqualityCheck(PathologicalString pathologicalString) + { + EqualityCheck(pathologicalString.Value); + } + + private static void EqualityCheck(string? str) + { + if (str is null) { return; } + IReadOnlyList splitted; + try + { + splitted = str.SplitEscaped(Joiner); + } + catch (ArgumentOutOfRangeException) + { + // Didn't fail the test, the input just had bad escapes + return; + } + string recombined = splitted.JoinEscaped(Joiner); + recombined.Should().BeEquivalentTo(str); + } +} \ No newline at end of file diff --git a/Deploy/DeployAll/Deployables.cs b/Deploy/DeployAll/Deployables.cs index a83936516..4048f1e2b 100644 --- a/Deploy/DeployAll/Deployables.cs +++ b/Deploy/DeployAll/Deployables.cs @@ -10,10 +10,10 @@ public static class Deployables { public const string ResultPath = "Deploy/bin/content"; - private const string clientProjFmt = "Barotrauma/BarotraumaClient/{0}Client.csproj"; - private const string serverProjFmt = "Barotrauma/BarotraumaServer/{0}Server.csproj"; + private const string ClientProjFmt = "Barotrauma/BarotraumaClient/{0}Client.csproj"; + private const string ServerProjFmt = "Barotrauma/BarotraumaServer/{0}Server.csproj"; - private static readonly ImmutableArray<(string Project, string Runtime)> platforms = new[] + private static readonly ImmutableArray<(string Project, string Runtime)> Platforms = new[] { ("Windows", "win-x64"), ("Mac", "osx-x64"), @@ -28,11 +28,11 @@ public static class Deployables Path.Combine(ResultPath, "readme.txt"), $"This is Barotrauma {configuration} v{version} ({gitBranch}, {gitRevision}) built on {DateTime.Now}"); - foreach (var (project, runtime) in platforms) + foreach (var (project, runtime) in Platforms) { string serverPath = Path.Combine(ResultPath, project, "Server"); - void checkVersion(string projPath) + void CheckVersion(string projPath) { Version projVersion = Version.Parse( XDocument.Load(projPath).Root? @@ -45,11 +45,11 @@ public static class Deployables } } - string serverProj = string.Format(serverProjFmt, project); - string clientProj = string.Format(clientProjFmt, project); + string serverProj = string.Format(ServerProjFmt, project); + string clientProj = string.Format(ClientProjFmt, project); - checkVersion(serverProj); - checkVersion(clientProj); + CheckVersion(serverProj); + CheckVersion(clientProj); Console.WriteLine( $"*** Building Barotrauma {configuration}{project} v{version} ({gitBranch}, {gitRevision}) to \"{Path.Combine(ResultPath, project)}\" ***"); diff --git a/Deploy/DeployAll/DotnetCmd.cs b/Deploy/DeployAll/DotnetCmd.cs index c6435e313..2ce19f8e6 100644 --- a/Deploy/DeployAll/DotnetCmd.cs +++ b/Deploy/DeployAll/DotnetCmd.cs @@ -16,7 +16,7 @@ public static class DotnetCmd { private const string DotnetAppName = "dotnet"; - private const string desiredRuntimeVersion = "6.0.8"; + private const string DesiredRuntimeVersion = "6.0.8"; public static void Publish(string projPath, string configuration, string runtime, string resultPath) { @@ -36,7 +36,7 @@ public static class DotnetCmd "/p:Platform=x64", "/p:ErrorOnDuplicatePublishOutputFiles=false", //TODO: fix our duplicate files "/p:RollForward=Disable", - $"/p:RuntimeFrameworkVersion={desiredRuntimeVersion}", + $"/p:RuntimeFrameworkVersion={DesiredRuntimeVersion}", "-o", resultPath }, diff --git a/Deploy/DeployAll/EgsAssistant.cs b/Deploy/DeployAll/EgsAssistant.cs new file mode 100644 index 000000000..c69cf4c0c --- /dev/null +++ b/Deploy/DeployAll/EgsAssistant.cs @@ -0,0 +1,143 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Xml; +using System.Xml.Linq; + +namespace DeployAll; + +public static class EgsAssistant +{ + private static string BuildToolFilePath + => Path.Combine(BuildToolExtractRootPath, true switch + { + _ when RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + => "Engine/Binaries/Win64/BuildPatchTool.exe", + _ when RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + => "Engine/Binaries/Linux/BuildPatchTool", + _ when RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + => "Engine/Binaries/Mac/BuildPatchTool", + _ => throw new Exception($"Unsupported host platform: {RuntimeInformation.OSDescription}") + }); + + private const string BuildToolExtractRootPath = "Deploy/bin/EpicBuildTool"; + private const string BuildToolConfig = $"{BuildToolExtractRootPath}/epic_build_tool_config.xml"; + + private const string CloudDir = $"{BuildToolExtractRootPath}/CloudDir"; + + private const string FileIgnoreListPath = $"{BuildToolExtractRootPath}/ignore.txt"; + private const string FileAttributeListPath = $"{BuildToolExtractRootPath}/fileattributes.txt"; + + public static void Upload(Version version, string configuration, string revision) + { + while (!File.Exists(BuildToolFilePath)) + { + Directory.CreateDirectory(BuildToolExtractRootPath); + if (Util.AskQuestion( + $"Epic BuildPatchTool not found. Extract it to {BuildToolExtractRootPath}, then enter Y to continue. Enter nothing to cancel.") + .AnsweredNo()) + { + return; + } + } + + XDocument? cfg = null; + while (!Util.TryLoadXml(BuildToolConfig, out cfg) + || cfg.Root!.Attributes().Any(attr => !attr.Value.IsValidEpicCfg())) + { + if (!File.Exists(BuildToolConfig)) + { + var doc = new XDocument( + new XElement("config")); + doc.Root!.Add(new XAttribute("OrganizationId", " ORGANIZATION ID ")); + doc.Root!.Add(new XAttribute("ProductId", " PRODUCT ID ")); + doc.Root!.Add(new XAttribute("ArtifactId", " ARTIFACT ID ")); + doc.Root!.Add(new XAttribute("ClientId", " BUILDPATCHTOOL CLIENT ID ")); + doc.Root!.Add(new XAttribute("ClientSecret", " BUILDPATCHTOOL CLIENT SECRET ")); + + var xmlWriterSettings = new XmlWriterSettings + { + Indent = true, + OmitXmlDeclaration = true, + NewLineOnAttributes = true + }; + + using var writer = XmlWriter.Create(BuildToolConfig, xmlWriterSettings); + doc.WriteTo(writer); + writer.Flush(); + } + + if (Util.AskQuestion( + $"Parameters for BuildPatchTool are missing or invalid. Fill in {BuildToolConfig} with the appropriate values, then enter Y to continue. Enter nothing to cancel.") + .AnsweredNo()) + { + return; + } + } + + Directory.CreateDirectory(CloudDir); + + XElement configElement = cfg.Root; + string organizationId = configElement.GetAttributeOrThrow("OrganizationId"); + string productId = configElement.GetAttributeOrThrow("ProductId"); + string artifactId = configElement.GetAttributeOrThrow("ArtifactId"); + string clientId = configElement.GetAttributeOrThrow("ClientId"); + string clientSecret = configElement.GetAttributeOrThrow("ClientSecret"); + + var supportedPlatforms = new (string Platform, string ExecutablePath)[] + { + ("Windows", "Barotrauma.exe"), + + // TODO: reevaluate macOS support for the Epic Games Store version of Barotrauma + // This was dropped because of QA difficulty and missing features on the platform + // but it may be possible to get it working well enough to be shipped. + //("Mac", "Barotrauma.app/Contents/MacOS/Barotrauma") + }; + + foreach ((string platform, string executablePath) in supportedPlatforms) + { + string RelativeToAbsolute(string relativePath) + => Path.Combine(Path.GetDirectoryName(executablePath) ?? "", relativePath).NormalizePathSeparators(); + + var filesToIgnore = new[] { "steam_api64.dll", "libsteam_api64.dylib", "libsteam_api64.so" } + .Select(RelativeToAbsolute) + .ToArray(); + File.WriteAllLines(FileIgnoreListPath, filesToIgnore); + var fileAttributes = platform == "Mac" + ? new[] { "DedicatedServer" } + : Array.Empty(); + fileAttributes = fileAttributes + .Select(RelativeToAbsolute) + .Select(f => $"\"{f}\" executable") + .ToArray(); + File.WriteAllLines(FileAttributeListPath, fileAttributes); + + var psi = new ProcessStartInfo + { + FileName = BuildToolFilePath, + ArgumentList = + { + $"-OrganizationId=\"{organizationId}\"", + $"-ProductId=\"{productId}\"", + $"-ArtifactId=\"{artifactId}\"", + $"-ClientId=\"{clientId}\"", + $"-ClientSecret=\"{clientSecret}\"", + "-mode=UploadBinary", + $"-BuildRoot=\"{Path.GetFullPath(Path.Combine(Deployables.ResultPath, platform, "Client")).NormalizePathSeparators()}\"", + $"-CloudDir=\"{Path.GetFullPath(CloudDir).NormalizePathSeparators()}\"", + $"-BuildVersion=\"{version}-{platform}{configuration}-{revision}\"", + $"-AppLaunch=\"{executablePath}\"", + "-AppArgs=\"\"", + $"-FileIgnoreList={Path.GetFullPath(FileIgnoreListPath).NormalizePathSeparators()}", + $"-FileAttributeList={Path.GetFullPath(FileAttributeListPath).NormalizePathSeparators()}" + }, + RedirectStandardOutput = false, + RedirectStandardError = false + }; + var process = Util.StartProcess(psi); + process.WaitForExit(); + } + } +} \ No newline at end of file diff --git a/Deploy/DeployAll/GitCmd.cs b/Deploy/DeployAll/GitCmd.cs index 624c418e3..c441707fd 100644 --- a/Deploy/DeployAll/GitCmd.cs +++ b/Deploy/DeployAll/GitCmd.cs @@ -5,13 +5,13 @@ namespace DeployAll; public static class GitCmd { - private const string gitCmdName = "git"; + private const string GitCmdName = "git"; private static ProcessStartInfo MakePsi(params string[] args) { var psi = new ProcessStartInfo { - FileName = gitCmdName, + FileName = GitCmdName, RedirectStandardError = true, RedirectStandardOutput = true }; diff --git a/Deploy/DeployAll/Program.cs b/Deploy/DeployAll/Program.cs index 3de060435..5f5c53202 100644 --- a/Deploy/DeployAll/Program.cs +++ b/Deploy/DeployAll/Program.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Xml.Linq; using DeployAll; +AppDomain.CurrentDomain.ProcessExit += (_, _) => Console.WriteLine("Bye!"); + while (!Directory.GetFiles(".").Any(f => f.EndsWith(".sln"))) { Directory.SetCurrentDirectory(".."); @@ -46,6 +48,13 @@ if (string.IsNullOrWhiteSpace(configuration)) { return; } Deployables.Generate(configuration, gameVersion, gitBranch, gitRevision); +if (Util.AskQuestion("Would you like to upload the generated builds to EGS? [y/n]") + .AnsweredYes()) +{ + EgsAssistant.Upload(gameVersion, configuration, gitRevision); +} + + if (Util.AskQuestion("Would you like to upload the generated builds to Steam? [y/n]") .AnsweredNo()) { return; } diff --git a/Deploy/DeployAll/SteamPipeAssistant.cs b/Deploy/DeployAll/SteamPipeAssistant.cs index e111af644..6e29dd98b 100644 --- a/Deploy/DeployAll/SteamPipeAssistant.cs +++ b/Deploy/DeployAll/SteamPipeAssistant.cs @@ -34,7 +34,7 @@ public static class SteamPipeAssistant } } - private static string steamCmdUrl + private static string SteamCmdUrl => true switch { _ when RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -46,7 +46,7 @@ public static class SteamPipeAssistant _ => throw new Exception($"Unsupported host platform: {RuntimeInformation.OSDescription}") }; - private static string[] steamCmdFilenames + private static string[] SteamCmdFilenames => true switch { _ when RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -71,9 +71,9 @@ public static class SteamPipeAssistant Util.RecreateDirectory(SteamCmdPath); - var steamCmdPkg = Util.DownloadFile(steamCmdUrl).ToArray(); + var steamCmdPkg = Util.DownloadFile(SteamCmdUrl, out _).ToArray(); - if (Path.GetExtension(steamCmdUrl) == ".zip") + if (Path.GetExtension(SteamCmdUrl) == ".zip") { using var memStream = new MemoryStream(steamCmdPkg); using ZipArchive archive = new ZipArchive(memStream, ZipArchiveMode.Read); @@ -81,7 +81,7 @@ public static class SteamPipeAssistant } else { - string downloadResultPath = Path.Combine(SteamCmdPath, Path.GetFileName(steamCmdUrl)); + string downloadResultPath = Path.Combine(SteamCmdPath, Path.GetFileName(SteamCmdUrl)); File.WriteAllBytes(downloadResultPath, steamCmdPkg); var psi = new ProcessStartInfo @@ -102,7 +102,7 @@ public static class SteamPipeAssistant File.Delete(downloadResultPath); - foreach (var filename in steamCmdFilenames) + foreach (var filename in SteamCmdFilenames) { psi = new ProcessStartInfo { @@ -126,7 +126,7 @@ public static class SteamPipeAssistant private const string ScriptPath = "Deploy/bin/scripts"; private const string BuildOutput = "Deploy/bin/output"; - private const string appIdScriptFileFmt = "app_{0}.vdf"; + private const string AppIdScriptFileFmt = "app_{0}.vdf"; private const ulong ClientAppId = 602960; private const ulong ClientWindowsDepotId = 602961; @@ -194,14 +194,14 @@ public static class SteamPipeAssistant new SingleItem("contentroot", Path.GetFullPath(Deployables.ResultPath)), new SingleItem("setlive", appId switch { - ClientAppId => "experimental", - ServerAppId => "development", + ClientAppId => "refactor_our_souls", + ServerAppId => "refactor_our_souls", _ => throw new InvalidOperationException() }), new SingleItem("preview", "0"), depotScripts); - var scriptFileName = Path.Combine(ScriptPath, string.Format(appIdScriptFileFmt, appId)); + var scriptFileName = Path.Combine(ScriptPath, string.Format(AppIdScriptFileFmt, appId)); File.WriteAllText(scriptFileName, script.ToString()); } @@ -223,7 +223,7 @@ public static class SteamPipeAssistant ProcessStartInfo psi = new ProcessStartInfo { - FileName = Path.Combine(SteamCmdPath, steamCmdFilenames.First()), + FileName = Path.Combine(SteamCmdPath, SteamCmdFilenames.First()), ArgumentList = { "+login", @@ -233,13 +233,13 @@ public static class SteamPipeAssistant RedirectStandardError = false }; - void addScriptCmd(ulong appId) + void AddScriptCmd(ulong appId) { psi.ArgumentList.Add("+run_app_build"); - psi.ArgumentList.Add(Path.GetFullPath(Path.Combine(ScriptPath, string.Format(appIdScriptFileFmt, appId)))); + psi.ArgumentList.Add(Path.GetFullPath(Path.Combine(ScriptPath, string.Format(AppIdScriptFileFmt, appId)))); } - addScriptCmd(ClientAppId); - if (configuration == "Release") { addScriptCmd(ServerAppId); } + AddScriptCmd(ClientAppId); + if (configuration == "Release") { AddScriptCmd(ServerAppId); } psi.ArgumentList.Add("+quit"); var process = Util.StartProcess(psi); diff --git a/Deploy/DeployAll/Util.cs b/Deploy/DeployAll/Util.cs index 6962ac512..0a4fc8091 100644 --- a/Deploy/DeployAll/Util.cs +++ b/Deploy/DeployAll/Util.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net.Http; +using System.Xml.Linq; namespace DeployAll; @@ -64,13 +66,16 @@ public static class Util DeleteDirectory(path); Directory.CreateDirectory(path); } - - public static IReadOnlyList DownloadFile(string url) + + public static IReadOnlyList DownloadFile(string url, out string finalUrl) { - var httpClient = new HttpClient(); + finalUrl = url; + + using var httpClient = new HttpClient(); var response = httpClient.Send(new HttpRequestMessage( HttpMethod.Get, new Uri(url))); + finalUrl = response.RequestMessage?.RequestUri?.AbsoluteUri ?? url; using var stream = response.Content.ReadAsStream(); using var reader = new BinaryReader(stream); @@ -100,6 +105,50 @@ public static class Util public static bool AnsweredNo(this string answer) => !answer.AnsweredYes(); + public static bool IsValidEpicCfg(this char c) + => char.IsDigit(c) + || c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '-' or '_' or '+' or '/'; + + public static bool IsValidEpicCfg(this string s) + => !string.IsNullOrEmpty(s) && s.All(IsValidEpicCfg); + + public static bool TryLoadXml(string path, [NotNullWhen(returnValue: true)]out XDocument? doc) + { + try + { + doc = XDocument.Load(path); + return true; + } + catch + { + doc = null; + return false; + } + } + + public static string GetAttributeOrThrow(this XElement element, string attributeName) + { + var attribute = element + .Attributes() + .FirstOrDefault(e => e.Name.LocalName.Equals(attributeName, StringComparison.OrdinalIgnoreCase)); + if (attribute != null + && !string.IsNullOrEmpty(attribute.Value)) + { + return attribute.Value; + } + + throw new Exception($"{attributeName} is not set"); + } + + public static string ThrowIfNullOrEmpty(this string? s, string msg) + { + if (string.IsNullOrEmpty(s)) { throw new Exception(msg); } + return s; + } + + public static string NormalizePathSeparators(this string s) + => s.Replace("\\", "/"); + public static Process StartProcess(ProcessStartInfo info) => Process.Start(info) ?? throw new Exception($"Failed to start process \"{info.FileName}\""); diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Achievements/AchievementStats.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Achievements/AchievementStats.cs new file mode 100644 index 000000000..35135a685 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Achievements/AchievementStats.cs @@ -0,0 +1,59 @@ +#nullable enable +using System; +using System.Collections.Immutable; + +namespace Barotrauma; + +public enum AchievementStat +{ + GameLaunchCount, + MonstersKilled, + HumansKilled, + KMsTraveled, + HoursInEditor, + MetersTraveled, + MinutesInEditor +} + +public static class AchievementStatExtension +{ + public static readonly ImmutableArray SteamStats = new [] + { + AchievementStat.KMsTraveled, + AchievementStat.HoursInEditor, + AchievementStat.HumansKilled, + AchievementStat.MonstersKilled + }.ToImmutableArray(); + + public static readonly ImmutableArray EosStats = new [] + { + AchievementStat.MetersTraveled, + AchievementStat.MinutesInEditor, + AchievementStat.HumansKilled, + AchievementStat.MonstersKilled + }.ToImmutableArray(); + + public static bool IsFloatStat(this AchievementStat stat) => + stat switch + { + AchievementStat.KMsTraveled => true, + AchievementStat.HoursInEditor => true, + _ => false + }; + + public static AchievementStat FromIdentifier(Identifier identifier) => + Enum.TryParse(value: identifier.ToString().ToLowerInvariant(), ignoreCase: true, result: out AchievementStat stat) + ? stat + : throw new ArgumentException($"Invalid achievement stat identifier \"{identifier}\""); + + public static (AchievementStat Stat, int Value) ToEos(this AchievementStat stat, float value) => + stat switch + { + AchievementStat.KMsTraveled => (AchievementStat.MetersTraveled, (int)MathF.Floor(value * 1000f)), + AchievementStat.HoursInEditor => (AchievementStat.MinutesInEditor, (int)MathF.Floor(value * 60f)), + _ => (stat, (int)value) + }; + + public static (AchievementStat Stat, float Value) ToSteam(this AchievementStat stat, float value) => + (stat, value); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/BarotraumaCore.csproj b/Libraries/BarotraumaLibs/BarotraumaCore/BarotraumaCore.csproj new file mode 100644 index 000000000..cb7dea18c --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/BarotraumaCore.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + Barotrauma + disable + enable + + + + full + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + true + x64 + + + + full + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + true + x64 + + + + + + + diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/ColorExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/ColorExtensions.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/ColorExtensions.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/ColorExtensions.cs diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/EnumerableExtensionsCore.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/EnumerableExtensionsCore.cs new file mode 100644 index 000000000..e78ac9442 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/EnumerableExtensionsCore.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma.Extensions; + +public static class EnumerableExtensionsCore +{ + public static ImmutableDictionary ToImmutableDictionary(this IEnumerable<(TKey, TValue)> enumerable) + where TKey : notnull + { + return enumerable.ToDictionary().ToImmutableDictionary(); + } + + public static Dictionary ToDictionary(this IEnumerable<(TKey, TValue)> enumerable) + where TKey : notnull + { + var dictionary = new Dictionary(); + foreach (var (k,v) in enumerable) + { + dictionary.Add(k, v); + } + return dictionary; + } + + [return: NotNullIfNotNull("immutableDictionary")] + public static Dictionary? ToMutable(this ImmutableDictionary? immutableDictionary) + where TKey : notnull + { + if (immutableDictionary == null) { return null; } + return new Dictionary(immutableDictionary); + } + +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/PointExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/PointExtensions.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/PointExtensions.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/PointExtensions.cs diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/RectangleExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/RectangleExtensions.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/RectangleExtensions.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/RectangleExtensions.cs diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/RngExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/RngExtensions.cs new file mode 100644 index 000000000..7a2909251 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/RngExtensions.cs @@ -0,0 +1,11 @@ +using System; +namespace Barotrauma.Extensions; + +public static class RngExtensions +{ + public static float Range(this Random rng, float minimum, float maximum) + => (float)rng.Range((double)minimum, (double)maximum); + + public static double Range(this Random rng, double minimum, double maximum) + => rng.NextDouble() * (maximum - minimum) + minimum; +} diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringExtensions.cs new file mode 100644 index 000000000..7ab5bc366 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringExtensions.cs @@ -0,0 +1,66 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Barotrauma +{ + public static class StringExtensions + { + [return: NotNullIfNotNull("fallback")] + public static string? FallbackNullOrEmpty(this string? s, string? fallback) => string.IsNullOrEmpty(s) ? fallback : s; + + public static bool IsNullOrEmpty([NotNullWhen(returnValue: false)]this string? s) => string.IsNullOrEmpty(s); + public static bool IsNullOrWhiteSpace([NotNullWhen(returnValue: false)]this string? s) => string.IsNullOrWhiteSpace(s); + public static string RemoveFromEnd(this string s, string substr, StringComparison stringComparison = StringComparison.Ordinal) + => s.EndsWith(substr, stringComparison) ? s.Substring(0, s.Length - substr.Length) : s; + + public static bool IsTrueString(this string s) + => s.Length == 4 + && s[0] is 'T' or 't' + && s[1] is 'R' or 'r' + && s[2] is 'U' or 'u' + && s[3] is 'E' or 'e'; + + public static string JoinEscaped(this IEnumerable strings, char joiner) + { + return string.Join( + joiner, + strings.Select(s => s + .Replace("\\", "\\\\") + .Replace(joiner.ToString(), $"\\{joiner}"))); + } + + public static IReadOnlyList SplitEscaped(this string str, char joiner) + { + bool isEscape(int i) + { + return i >= 0 && str[i] == '\\' && !isEscape(i - 1); + } + + var retVal = new List(); + int lastSplitIndex = 0; + for (int i = 0; i < str.Length; i++) + { + if (str[i] == joiner && !isEscape(i - 1)) + { + retVal.Add(str[lastSplitIndex..i]); + lastSplitIndex = i + 1; + } + if (isEscape(i) && (i >= str.Length - 1 || (str[i+1] != joiner && str[i+1] != '\\'))) + { + throw new ArgumentOutOfRangeException($"The string \"{str}\" could not have been produced by a call to {nameof(JoinEscaped)} with joiner '{joiner}'"); + } + } + retVal.Add(str[lastSplitIndex..]); + for (int i = 0; i < retVal.Count; i++) + { + retVal[i] = retVal[i] + .Replace($"\\{joiner}", joiner.ToString()) + .Replace("\\\\", "\\"); + } + return retVal; + } + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringFormatter.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringFormatter.cs similarity index 97% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/StringFormatter.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringFormatter.cs index 64fd0ab3b..96bd5c15c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Extensions/StringFormatter.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StringFormatter.cs @@ -121,7 +121,7 @@ namespace Barotrauma } } - public static ICollection ParseCommaSeparatedStringToCollection(string input, ICollection texts = null, bool convertToLowerInvariant = true) + public static ICollection ParseCommaSeparatedStringToCollection(string input, ICollection? texts = null, bool convertToLowerInvariant = true) { if (texts == null) { @@ -149,7 +149,7 @@ namespace Barotrauma return texts; } - public static ICollection ParseSeparatedStringToCollection(string input, string[] separators, ICollection texts = null, bool convertToLowerInvariant = true) + public static ICollection ParseSeparatedStringToCollection(string input, string[] separators, ICollection? texts = null, bool convertToLowerInvariant = true) { if (texts == null) { diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/StructExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StructExtensions.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/StructExtensions.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/StructExtensions.cs diff --git a/Barotrauma/BarotraumaShared/SharedSource/Extensions/VectorExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Extensions/VectorExtensions.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Extensions/VectorExtensions.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Extensions/VectorExtensions.cs diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/AccountId.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/AccountId.cs similarity index 60% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/AccountId.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/AccountId.cs index 47fc84fd3..b93df20ef 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/AccountId.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/AccountId.cs @@ -2,9 +2,10 @@ namespace Barotrauma.Networking { - abstract class AccountId + public abstract class AccountId { public abstract string StringRepresentation { get; } + public abstract string EosStringRepresentation { get; } public static Option Parse(string str) => ReflectionUtils.ParseDerived(str); @@ -15,10 +16,12 @@ namespace Barotrauma.Networking public override string ToString() => StringRepresentation; - public static bool operator ==(AccountId a, AccountId b) - => a.Equals(b); + public static bool operator ==(AccountId? a, AccountId? b) + => a is null + ? b is null + : a.Equals(b); - public static bool operator !=(AccountId a, AccountId b) + public static bool operator !=(AccountId? a, AccountId? b) => !(a == b); } } \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/EpicAccountId.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/EpicAccountId.cs new file mode 100644 index 000000000..e00574a08 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/EpicAccountId.cs @@ -0,0 +1,32 @@ +#nullable enable +using System; +namespace Barotrauma.Networking; + +public sealed class EpicAccountId : AccountId +{ + private EpicAccountId(string value) + { + EosStringRepresentation = value.ToLowerInvariant(); + } + + private const string prefix = "EPIC_"; + + public override string StringRepresentation => $"{prefix}{EosStringRepresentation.ToUpperInvariant()}"; + public override string EosStringRepresentation { get; } + + public override bool Equals(object? obj) + => obj is EpicAccountId otherId + && otherId.EosStringRepresentation.Equals(EosStringRepresentation, StringComparison.OrdinalIgnoreCase); + + public override int GetHashCode() + => EosStringRepresentation.GetHashCode(StringComparison.OrdinalIgnoreCase); + + public new static Option Parse(string str) + { + if (str.IsNullOrWhiteSpace()) { return Option.None; } + if (str.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { str = str[prefix.Length..]; } + if (!str.IsHexString()) { return Option.None; } + + return Option.Some(new EpicAccountId(str)); + } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/SteamId.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/SteamId.cs similarity index 96% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/SteamId.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/SteamId.cs index 5ae6f4fe5..69fb000aa 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/AccountId/SteamId.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/AccountId/SteamId.cs @@ -1,14 +1,17 @@ #nullable enable using System; +using System.Globalization; namespace Barotrauma.Networking { - sealed class SteamId : AccountId + public sealed class SteamId : AccountId { public readonly UInt64 Value; public override string StringRepresentation { get; } + public override string EosStringRepresentation => Value.ToString(CultureInfo.InvariantCulture); + /// Based on information found here: https://developer.valvesoftware.com/wiki/SteamID /// ------------------------------------------------------------------------------------ /// A SteamID is a 64-bit value (16 hexadecimal digits) that's broken up as follows: diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/Address.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/Address.cs similarity index 95% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/Address.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/Address.cs index f5ca6da14..67b3870e4 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/Address.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/Address.cs @@ -2,7 +2,7 @@ namespace Barotrauma.Networking { - abstract class Address + public abstract class Address { public abstract string StringRepresentation { get; } diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/EosP2PAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/EosP2PAddress.cs new file mode 100644 index 000000000..aaf938b7c --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/EosP2PAddress.cs @@ -0,0 +1,38 @@ +#nullable enable +using System; +using System.Linq; +using System.Security.Cryptography; +namespace Barotrauma.Networking; + +public sealed class EosP2PAddress : P2PAddress +{ + private const string prefix = "EOS_"; + + public readonly string EosStringRepresentation; + + public EosP2PAddress(string value) + { + EosStringRepresentation = value.ToLowerInvariant(); + } + + public new static Option Parse(string addressStr) + { + if (addressStr.StartsWith(prefix)) { addressStr = addressStr[prefix.Length..]; } + if (!addressStr.IsHexString()) { return Option.None; } + + return Option.Some(new EosP2PAddress(addressStr)); + } + + public override string StringRepresentation => $"{prefix}{EosStringRepresentation}"; + public override bool IsLocalHost => false; + + public override bool Equals(object? obj) + => obj is EosP2PAddress other + && other.EosStringRepresentation.ToString().Equals(EosStringRepresentation.ToString(), StringComparison.OrdinalIgnoreCase); + + public override int GetHashCode() + { + using var md5 = MD5.Create(); + return unchecked((int)ToolBoxCore.StringToUInt32Hash(EosStringRepresentation, md5)); + } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/LidgrenAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/LidgrenAddress.cs similarity index 98% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/LidgrenAddress.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/LidgrenAddress.cs index bb8eeaa40..3c925bb0a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/LidgrenAddress.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/LidgrenAddress.cs @@ -6,7 +6,7 @@ using System.Net.Sockets; namespace Barotrauma.Networking { - sealed class LidgrenAddress : Address + public sealed class LidgrenAddress : Address { public readonly IPAddress NetAddress; diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/P2PAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/P2PAddress.cs new file mode 100644 index 000000000..e6dd52858 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/P2PAddress.cs @@ -0,0 +1,7 @@ +namespace Barotrauma.Networking; + +public abstract class P2PAddress : Address +{ + public new static Option Parse(string str) + => Address.Parse(str).Bind(addr => addr is P2PAddress p2pAddr ? Option.Some(p2pAddr) : Option.None); +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/PipeAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/PipeAddress.cs similarity index 91% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/PipeAddress.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/PipeAddress.cs index 1508dc239..58d1150fe 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/PipeAddress.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/PipeAddress.cs @@ -2,7 +2,7 @@ namespace Barotrauma.Networking { - sealed class PipeAddress : Address + public sealed class PipeAddress : Address { public override string StringRepresentation => "PIPE"; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/SteamP2PAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/SteamP2PAddress.cs similarity index 81% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/SteamP2PAddress.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/SteamP2PAddress.cs index 641815caa..5cba33fda 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/SteamP2PAddress.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/SteamP2PAddress.cs @@ -2,7 +2,7 @@ namespace Barotrauma.Networking { - sealed class SteamP2PAddress : Address + public sealed class SteamP2PAddress : P2PAddress { public readonly SteamId SteamId; @@ -19,11 +19,7 @@ namespace Barotrauma.Networking => SteamId.Parse(endpointStr).Select(steamId => new SteamP2PAddress(steamId)); public override bool Equals(object? obj) - => obj switch - { - SteamP2PAddress otherAddress => this == otherAddress, - _ => false - }; + => obj is SteamP2PAddress otherAddress && this == otherAddress; public override int GetHashCode() => SteamId.GetHashCode(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/UnknownAddress.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/UnknownAddress.cs similarity index 87% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/UnknownAddress.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/UnknownAddress.cs index 394d9c56d..ad57c6a3f 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/Address/UnknownAddress.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/Address/UnknownAddress.cs @@ -2,7 +2,7 @@ namespace Barotrauma.Networking { - sealed class UnknownAddress : Address + public sealed class UnknownAddress : Address { public override string StringRepresentation => "Hidden"; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkEnums.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/NetworkEnums.cs similarity index 65% rename from Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkEnums.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/NetworkEnums.cs index 03712e66f..b7c9866e0 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Networking/Primitives/NetworkEnums.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Networking/Primitives/NetworkEnums.cs @@ -5,19 +5,18 @@ namespace Barotrauma.Networking public enum DeliveryMethod : int { Unreliable = 0x0, - Reliable = 0x1, - ReliableOrdered = 0x2 + Reliable = 0x1 } public enum ConnectionInitialization : int { //used by all peer implementations - SteamTicketAndVersion = 0x1, + AuthInfoAndVersion = 0x1, ContentPackageOrder = 0x2, Password = 0x3, Success = 0x0, - //used only by SteamP2P implementations + //used only by P2P implementations ConnectionStarted = 0x4 } @@ -29,10 +28,11 @@ namespace Barotrauma.Networking IsCompressed = 0x1, IsConnectionInitializationStep = 0x2, - //used only by SteamP2P implementations + //used only by P2P implementations IsDisconnectMessage = 0x4, IsServerMessage = 0x8, - IsHeartbeatMessage = 0x10 + IsHeartbeatMessage = 0x10, + IsDataFragment = 0x20 } public static class NetworkEnumExtensions @@ -40,7 +40,6 @@ namespace Barotrauma.Networking public static bool IsCompressed(this PacketHeader h) => h.HasFlag(PacketHeader.IsCompressed); - #warning TODO: remove? public static bool IsConnectionInitializationStep(this PacketHeader h) => h.HasFlag(PacketHeader.IsConnectionInitializationStep); @@ -52,6 +51,17 @@ namespace Barotrauma.Networking public static bool IsHeartbeatMessage(this PacketHeader h) => h.HasFlag(PacketHeader.IsHeartbeatMessage); + + public static bool IsDataFragment(this PacketHeader h) + => h.HasFlag(PacketHeader.IsDataFragment); + } + + public static class NetworkMagicStrings + { + // This separator exists because Lidgren's disconnect messages + // can only readily support strings. We want to send something that + // isn't exactly a string, so we use this as part of its encoding. + public const string LidgrenDisconnectSeparator = "}Separator["; } } diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Social/FriendStatus.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Social/FriendStatus.cs new file mode 100644 index 000000000..b3b46eee6 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Social/FriendStatus.cs @@ -0,0 +1,9 @@ +namespace Barotrauma; + +public enum FriendStatus +{ + Offline, + NotPlaying, + PlayingAnotherGame, + PlayingBarotrauma +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/CollectionConcat.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/CollectionConcat.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/CollectionConcat.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/CollectionConcat.cs diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Either.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Either.cs similarity index 92% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/Either.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/Either.cs index 8d863767f..ffee31838 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Either.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Either.cs @@ -27,6 +27,14 @@ namespace Barotrauma public static bool operator !=(Either? a, Either? b) => !(a == b); + + public V Match(Func t, Func u) + => this switch + { + EitherT e => t(e.Value), + EitherU e => u(e.Value), + _ => throw new Exception("Invalid Either type") + }; } public sealed class EitherT : Either where T : notnull where U : notnull diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/GameVersion.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/GameVersion.cs new file mode 100644 index 000000000..3460e7db8 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/GameVersion.cs @@ -0,0 +1,9 @@ +using System; +using System.Reflection; +namespace Barotrauma; + +public static class GameVersion +{ + public static readonly Version CurrentVersion + = Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0,0,0,0); +} diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/IEnumerableExtensionsCore.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/IEnumerableExtensionsCore.cs new file mode 100644 index 000000000..76fce3681 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/IEnumerableExtensionsCore.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Barotrauma.Extensions; + +public static class IEnumerableExtensionsCore +{ + /// + /// Returns the maximum element in a given enumerable, or null if there + /// aren't any elements in the input. + /// + /// Input collection + /// Maximum element or null + public static T? MaxOrNull(this IEnumerable enumerable) where T : struct, IComparable + { + T? retVal = null; + foreach (T v in enumerable) + { + if (!retVal.HasValue || v.CompareTo(retVal.Value) > 0) { retVal = v; } + } + return retVal; + } + + public static TOut? MaxOrNull(this IEnumerable enumerable, Func conversion) + where TOut : struct, IComparable + => enumerable.Select(conversion).MaxOrNull(); + + public static int FindIndex(this IReadOnlyList list, Predicate predicate) + { + for (int i = 0; i < list.Count; i++) + { + if (predicate(list[i])) { return i; } + } + return -1; + } + + /// + /// Same as FirstOrDefault but will always return null instead of default(T) when no element is found + /// + public static T? FirstOrNull(this IEnumerable source, Func predicate) where T : struct + => source.FirstOrNone(predicate).TryUnwrap(out T t) ? t : null; + + public static T? FirstOrNull(this IEnumerable source) where T : struct + => source.FirstOrNone().TryUnwrap(out T t) ? t : null; + + public static Option FirstOrNone(this IEnumerable source, Func predicate) where T : notnull + { + foreach (T t in source) + { + if (predicate(t)) { return Option.Some(t); } + } + return Option.None; + } + + public static Option FirstOrNone(this IEnumerable source) where T : notnull + { + using IEnumerator enumerator = source.GetEnumerator(); + return enumerator.MoveNext() + ? Option.Some(enumerator.Current) + : Option.None; + } + + public static IEnumerable NotNone(this IEnumerable> source) where T : notnull + { + foreach (var o in source) + { + if (o.TryUnwrap(out var v)) { yield return v; } + } + } + + public static IEnumerable Successes( + this IEnumerable> source) + where TSuccess : notnull + where TFailure : notnull + => source + .OfType>() + .Select(s => s.Value); + + public static IEnumerable Failures( + this IEnumerable> source) + where TSuccess : notnull + where TFailure : notnull + => source + .OfType>() + .Select(f => f.Error); +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/Identifier.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Identifier.cs similarity index 82% rename from Barotrauma/BarotraumaShared/SharedSource/ContentManagement/Identifier.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/Identifier.cs index 7fb3453ce..e1b8f7c0d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/ContentManagement/Identifier.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Identifier.cs @@ -32,16 +32,16 @@ namespace Barotrauma => IsEmpty ? id : this; public Identifier Replace(in Identifier subStr, in Identifier newStr) - => Replace(subStr.Value ?? "", newStr.Value ?? ""); + => Replace(subStr.Value, newStr.Value); public Identifier Replace(string subStr, string newStr) - => (Value?.Replace(subStr, newStr, StringComparison.OrdinalIgnoreCase)).ToIdentifier(); + => Value.Replace(subStr, newStr, StringComparison.OrdinalIgnoreCase).ToIdentifier(); public Identifier Remove(Identifier subStr) - => Remove(subStr.Value ?? ""); + => Remove(subStr.Value); public Identifier Remove(string subStr) - => (Value?.Remove(subStr, StringComparison.OrdinalIgnoreCase)).ToIdentifier(); + => Value.Remove(subStr, StringComparison.OrdinalIgnoreCase).ToIdentifier(); public override bool Equals(object? obj) => obj switch @@ -51,25 +51,25 @@ namespace Barotrauma _ => base.Equals(obj) }; - public bool StartsWith(string str) => Value?.StartsWith(str, StringComparison.OrdinalIgnoreCase) ?? str.IsNullOrEmpty(); + public bool StartsWith(string str) => Value.StartsWith(str, StringComparison.OrdinalIgnoreCase); - public bool StartsWith(Identifier id) => StartsWith(id.Value ?? ""); + public bool StartsWith(Identifier id) => StartsWith(id.Value); - public bool EndsWith(string str) => Value?.EndsWith(str, StringComparison.OrdinalIgnoreCase) ?? str.IsNullOrEmpty(); + public bool EndsWith(string str) => Value.EndsWith(str, StringComparison.OrdinalIgnoreCase); - public bool EndsWith(Identifier id) => EndsWith(id.Value ?? ""); + public bool EndsWith(Identifier id) => EndsWith(id.Value); public Identifier AppendIfMissing(string suffix) => EndsWith(suffix) ? this : $"{this}{suffix}".ToIdentifier(); public Identifier RemoveFromEnd(string suffix) - => (Value?.RemoveFromEnd(suffix, StringComparison.OrdinalIgnoreCase)).ToIdentifier(); + => Value.RemoveFromEnd(suffix, StringComparison.OrdinalIgnoreCase).ToIdentifier(); - public bool Contains(string str) => Value?.Contains(str, StringComparison.OrdinalIgnoreCase) ?? str.IsNullOrEmpty(); + public bool Contains(string str) => Value.Contains(str, StringComparison.OrdinalIgnoreCase); - public bool Contains(in Identifier id) => Contains(id.Value ?? ""); + public bool Contains(in Identifier id) => Contains(id.Value); - public override string ToString() => Value ?? ""; + public override string ToString() => Value; public override int GetHashCode() => HashCode; @@ -122,10 +122,10 @@ namespace Barotrauma public static bool operator !=(string str, in Identifier? identifier) => !(identifier == str); - internal int IndexOf(char c) => Value.IndexOf(c); + public int IndexOf(char c) => Value.IndexOf(c); - internal Identifier this[Range range] => Value[range].ToIdentifier(); - internal Char this[int i] => Value[i]; + public Identifier this[Range range] => Value[range].ToIdentifier(); + public Char this[int i] => Value[i]; } public static class IdentifierExtensions diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Janitor.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Janitor.cs new file mode 100644 index 000000000..9c9b467d8 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Janitor.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma; + +/// +/// This type is intended to be used in using statements to automatically +/// clean up resources that are allocated incrementally +/// +public readonly struct Janitor : IDisposable +{ + private readonly List cleanupActions; + private Janitor(List cleanupActions) + { + this.cleanupActions = cleanupActions; + } + + public static Janitor Start() + => new Janitor(new List()); + + /// + /// Give the janitor a new action to perform when disposed + /// + public void AddAction([NotNull]Action action) + { + // Null check to punish misuse early instead of having the Janitor blow up upon disposal. + // Make sure you use nullable contexts so the compiler will stop you instead! + if (action is null) + { + throw new ArgumentException($"Cannot add null as an action for {nameof(Janitor)}"); + } + cleanupActions.Add(action); + } + + /// + /// Relieve the janitor of all current duties, + /// i.e. all of the currently enqueued cleanup + /// actions are cleared and will not execute + /// + public void Dismiss() + => cleanupActions.Clear(); + + public void Dispose() + { + cleanupActions.ForEach(a => a()); + Dismiss(); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/JsonWebToken.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/JsonWebToken.cs new file mode 100644 index 000000000..b044b1ef2 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/JsonWebToken.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace Barotrauma; + +public static class UnixTime +{ + public static readonly DateTime UtcEpoch = new DateTime(year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, kind: DateTimeKind.Utc); + + public static Option ParseUtc(string str) + { + if (!ulong.TryParse(str, out var seconds)) { return Option.None; } + return Option.Some(UtcEpoch + TimeSpan.FromSeconds(seconds)); + } +} + +/// +/// URL-safe Base64. See https://datatracker.ietf.org/doc/html/rfc4648#section-5 +/// +public static class Base64Url +{ + public static bool IsBase64Url(this string str) + => str.All(c + => c is + (>= 'A' and <= 'Z') + or (>= 'a' and <= 'z') + or (>= '0' and <= '9') + or '-' or '_'); + + public static Option DecodeUtf8String(string encodedForm) + { + return DecodeBytes(encodedForm).Select(bytes => Encoding.UTF8.GetString(bytes.AsSpan())); + } + + public static Option> DecodeBytes(string encodedForm) + { + if (!encodedForm.IsBase64Url()) { return Option.None; } + string base64Form = encodedForm.Replace("-", "+").Replace("_", "/"); + base64Form += new string('=', (4 - (base64Form.Length % 4)) % 4); + return Option.Some(Convert.FromBase64String(base64Form).ToImmutableArray()); + } +} + +/// +/// Rudimentary JSON Web Token implementation. See https://en.wikipedia.org/wiki/JSON_Web_Token. +/// This is used by continuance tokens and ID tokens as part of their internal representation. +/// We can use the data encoded in them for some things, such as determining a token's expiry time. +/// +public readonly record struct JsonWebToken( + string Header, + string Payload, + string Signature) +{ + public bool IsValid => Header.IsBase64Url() && Payload.IsBase64Url() && Signature.IsBase64Url(); + + public override string ToString() + => $"{Header}.{Payload}.{Signature}"; + + public string HeaderDecoded => Base64Url.DecodeUtf8String(Header).Fallback(""); + public string PayloadDecoded => Base64Url.DecodeUtf8String(Payload).Fallback(""); + + public static Option Parse(string str) + { + if (str.Split(".") is not { Length: 3 } split) { return Option.None; } + var newToken = new JsonWebToken( + Header: split[0], + Payload: split[1], + Signature: split[2]); + if (!newToken.IsValid) { return Option.None; } + return Option.Some(newToken); + } +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/MathUtils.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs similarity index 98% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/MathUtils.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs index 1f116b00f..357a3335a 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/MathUtils.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/MathUtils.cs @@ -17,7 +17,7 @@ namespace Barotrauma Any = Left | Right | Top | Bottom | Center } - static class MathUtils + public static class MathUtils { public static Vector2 DiscardZ(this Vector3 vector) => new Vector2(vector.X, vector.Y); @@ -729,7 +729,7 @@ namespace Barotrauma return wrappedPoints; } - public static List GenerateJaggedLine(Vector2 start, Vector2 end, int iterations, float offsetAmount, Rectangle? bounds = null) + public static List GenerateJaggedLine(Vector2 start, Vector2 end, int iterations, float offsetAmount, Random rng, Rectangle? bounds = null) { List segments = new List { @@ -749,7 +749,7 @@ namespace Barotrauma Vector2 normal = Vector2.Normalize(endSegment - startSegment); normal = new Vector2(-normal.Y, normal.X); - midPoint += normal * Rand.Range(-offsetAmount, offsetAmount, Rand.RandSync.ServerAndClient); + midPoint += normal * rng.Range(-offsetAmount, offsetAmount); if (bounds.HasValue) { @@ -886,6 +886,7 @@ namespace Barotrauma // https://stackoverflow.com/questions/3874627/floating-point-comparison-functions-for-c-sharp public static bool NearlyEqual(float a, float b, float epsilon = 0.0001f) { + // ReSharper disable once CompareOfFloatsByEqualityOperator if (a == b) { //shortcut, handles infinities @@ -1089,9 +1090,19 @@ namespace Barotrauma { return vals.Max(); } + + public static uint RoundUpToPowerOfTwo(uint val) + { + // Handle the input exceeding the max power of two uint can represent + if (val > (uint.MaxValue >> 1)) { return (uint.MaxValue >> 1) + 1; } + + uint po2 = 1; + while (val > po2) { po2 <<= 1; } + return po2; + } } - class CompareCW : IComparer + public class CompareCW : IComparer { private Vector2 center; @@ -1128,7 +1139,7 @@ namespace Barotrauma } } - class CompareCCW : IComparer + public class CompareCCW : IComparer { private Vector2 center; diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/NamedEvent.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/NamedEvent.cs similarity index 62% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/NamedEvent.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/NamedEvent.cs index 3f80b5500..0d6283bc2 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/NamedEvent.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/NamedEvent.cs @@ -1,35 +1,28 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; namespace Barotrauma { - internal sealed class NamedEvent : IDisposable + public sealed class NamedEvent : IDisposable { - private readonly Dictionary> events = new Dictionary>(); + private readonly ConcurrentDictionary> events = new ConcurrentDictionary>(); public void Register(Identifier identifier, Action action) { - if (HasEvent(identifier)) + if (!events.TryAdd(identifier, action)) { throw new ArgumentException($"Event with the identifier \"{identifier}\" has already been registered.", nameof(identifier)); } - - events.Add(identifier, action); } public void RegisterOverwriteExisting(Identifier identifier, Action action) { - if (HasEvent(identifier)) - { - Deregister(identifier); - } - - Register(identifier, action); + events.AddOrUpdate(identifier, action, (k, v) => action); } public void Deregister(Identifier identifier) { - events.Remove(identifier); + events.TryRemove(identifier, out _); } public void TryDeregister(Identifier identifier) @@ -38,7 +31,8 @@ namespace Barotrauma Deregister(identifier); } - public bool HasEvent(Identifier identifier) => events.ContainsKey(identifier); + public bool HasEvent(Identifier identifier) + => events.ContainsKey(identifier); public void Invoke(T data) { diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/OneOf.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/OneOf.cs new file mode 100644 index 000000000..4fe11c542 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/OneOf.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma; + +/// +/// Discriminated union of three types. +/// Essentially the same thing as Either<T1, T2>, except for three types instead of two types. +/// +public readonly struct OneOf + where T1 : notnull + where T2 : notnull + where T3 : notnull +{ + private readonly Option value1; + private readonly Option value2; + private readonly Option value3; + + private OneOf(Option value1, Option value2, Option value3) + { + this.value1 = value1; + this.value2 = value2; + this.value3 = value3; + } + + public static implicit operator OneOf(T1 value1) + => new OneOf(value1: Option.Some(value1), value2: Option.None, value3: Option.None); + public static implicit operator OneOf(T2 value2) + => new OneOf(value1: Option.None, value2: Option.Some(value2), value3: Option.None); + public static implicit operator OneOf(T3 value3) + => new OneOf(value1: Option.None, value2: Option.None, value3: Option.Some(value3)); + + public bool TryGet([NotNullWhen(returnValue: true)] out T1? t1) + => value1.TryUnwrap(out t1); + public bool TryGet([NotNullWhen(returnValue: true)] out T2? t2) + => value2.TryUnwrap(out t2); + public bool TryGet([NotNullWhen(returnValue: true)] out T3? t3) + => value3.TryUnwrap(out t3); + + private static string ObjectToStringWithType(T obj) + => $"{obj}: {typeof(T).Name}"; + + public override string ToString() + => $"OneOf<{typeof(T1).Name}, {typeof(T2).Name}, {typeof(T3).Name}>(" + + value1.Select(ObjectToStringWithType) + .Fallback(value2.Select(ObjectToStringWithType)) + .Fallback(value3.Select(ObjectToStringWithType)) + .Fallback("None") + + ")"; +} \ No newline at end of file diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Option/Option.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Option/Option.cs similarity index 94% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/Option/Option.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/Option/Option.cs index 63e0d5af6..75714375c 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Option/Option.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Option/Option.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; namespace Barotrauma { @@ -41,6 +42,9 @@ namespace Barotrauma public Option Bind(Func> binder) where TType : notnull => TryUnwrap(out T? selfValue) ? binder(selfValue) : Option.None; + public async Task> Bind(Func>> binder) where TType : notnull + => TryUnwrap(out T? selfValue) ? await binder(selfValue) : Option.None; + public T Match(Func some, Func none) => TryUnwrap(out T? selfValue) ? some(selfValue) : none(); diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Range.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Range.cs similarity index 100% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/Range.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/Range.cs diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/ReflectionUtils.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/ReflectionUtils.cs similarity index 79% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/ReflectionUtils.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/ReflectionUtils.cs index feedf094a..b89762131 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/ReflectionUtils.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/ReflectionUtils.cs @@ -15,6 +15,22 @@ namespace Barotrauma private static readonly Dictionary>> cachedDerivedNonAbstract = new Dictionary>>(); + public static T GetValueFromStaticProperty(this PropertyInfo property) + { + if (property.GetMethod is not { IsStatic: true }) + { + throw new ArgumentException($"Property {property} is not static"); + } + + var value = property.GetValue(obj: null); + if (value is not T castValue) + { + throw new ArgumentException($"Property {property} is null or not of type {typeof(T)}"); + } + + return castValue; + } + public static IEnumerable GetDerivedNonAbstract() { Type t = typeof(T); @@ -35,7 +51,19 @@ namespace Barotrauma return newArray; } - public static Option ParseDerived(TInput input) where TInput : notnull where TBase : notnull + public static Type? GetType(string nameWithNamespace) + { + if (Type.GetType(nameWithNamespace) is Type t) { return t; } + + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly?.GetType(nameWithNamespace) is Type t2) { return t2; } + + return null; + } + + public static Option ParseDerived(TInput input) + where TBase : notnull + where TInput : notnull { static Option none() => Option.None(); @@ -75,7 +103,7 @@ namespace Barotrauma public static string NameWithGenerics(this Type t) { if (!t.IsGenericType) { return t.Name; } - + string result = t.Name[..t.Name.IndexOf('`')]; result += $"<{string.Join(", ", t.GetGenericArguments().Select(NameWithGenerics))}>"; return result; diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Result.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Result.cs new file mode 100644 index 000000000..e2ea051f0 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Result.cs @@ -0,0 +1,120 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma +{ + public abstract class Result + where TSuccess: notnull + where TFailure: notnull + { + public abstract bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + + public static Success Success(TSuccess value) + => new Success(value); + + public static Failure Failure(TFailure error) + => new Failure(error); + + public abstract bool TryUnwrapSuccess([NotNullWhen(returnValue: true)] out TSuccess? value); + public abstract bool TryUnwrapFailure([NotNullWhen(returnValue: true)] out TFailure? value); + + public abstract override string ToString(); + + public static (Func> Success, Func> Failure) GetFactoryMethods() + => (Success, Failure); + + public static implicit operator Result(Result.UnspecifiedSuccess unspecifiedSuccess) + => Success(unspecifiedSuccess.Value); + + public static implicit operator Result(Result.UnspecifiedFailure unspecifiedFailure) + => Failure(unspecifiedFailure.Value); + + public void Match(Action success, Action failure) + { + if (TryUnwrapSuccess(out var successValue)) { success(successValue); } + if (TryUnwrapFailure(out var failureValue)) { failure(failureValue); } + } + } + + public sealed class Success : Result + where TSuccess: notnull + where TFailure: notnull + { + public readonly TSuccess Value; + public override bool IsSuccess => true; + + public override bool TryUnwrapSuccess([MaybeNullWhen(returnValue: false)] out TSuccess value) + { + value = Value; + return true; + } + + public override bool TryUnwrapFailure([MaybeNullWhen(returnValue: false)] out TFailure value) + { + value = default; + return false; + } + + public override string ToString() + => $"Success<{typeof(TSuccess).NameWithGenerics()}, {typeof(TFailure).NameWithGenerics()}>({Value})"; + + public Success(TSuccess value) + { + Value = value; + } + } + + public sealed class Failure : Result + where TSuccess: notnull + where TFailure: notnull + { + public readonly TFailure Error; + + public override bool IsSuccess => false; + + public override bool TryUnwrapSuccess([MaybeNullWhen(returnValue: false)] out TSuccess value) + { + value = default; + return false; + } + + public override bool TryUnwrapFailure([MaybeNullWhen(returnValue: false)] out TFailure value) + { + value = Error; + return true; + } + + public override string ToString() + => $"Failure<{typeof(TSuccess).NameWithGenerics()}, {typeof(TFailure).NameWithGenerics()}>({Error})"; + + public Failure(TFailure error) + { + Error = error; + } + } + + public static class Result + { + public readonly ref struct UnspecifiedSuccess + where TSuccess : notnull + { + internal readonly TSuccess Value; + internal UnspecifiedSuccess(TSuccess value) { Value = value; } + } + + public readonly ref struct UnspecifiedFailure + where TFailure : notnull + { + internal readonly TFailure Value; + internal UnspecifiedFailure(TFailure value) { Value = value; } + } + + public static UnspecifiedSuccess Success(TSuccess value) where TSuccess : notnull + => new UnspecifiedSuccess(value); + + public static UnspecifiedFailure Failure(TFailure value) where TFailure : notnull + => new UnspecifiedFailure(value); + } +} diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskExtensions.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskExtensions.cs new file mode 100644 index 000000000..367a77023 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskExtensions.cs @@ -0,0 +1,40 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace Barotrauma +{ + public static class TaskExtensionsCore + { + public static async Task> ToOptionTask(this Task nullableTask) where T : struct + { + var nullableResult = await nullableTask; + return nullableResult is { } result + ? Option.Some(result) + : Option.None; + } + + public static bool TryGetResult(this Task task, [NotNullWhen(returnValue: true)]out T? result) where T : notnull + { + if (task is Task { IsCompletedSuccessfully: true, Result: not null } castTask) + { + result = castTask.Result; + return true; + } +#if DEBUG + if (task.Exception != null) + { + var ex = task.Exception.GetInnermost(); + throw new InvalidOperationException($"Failed to get result from task: task failed with exception {ex.Message} ({ex.GetType()}) {ex.StackTrace}"); + } + if (task is not Task) + { + throw new InvalidOperationException($"Failed to get result from task: expected Task<{typeof(T).NameWithGenerics()}>, got {task.GetType().NameWithGenerics()}"); + } +#endif + result = default; + return false; + } + } +} diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskPool.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskPool.cs similarity index 58% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/TaskPool.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskPool.cs index f2b0fceb6..b2c004a48 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/TaskPool.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/TaskPool.cs @@ -1,33 +1,37 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading.Tasks; namespace Barotrauma { public static class TaskPool { + /// + /// Empty callback that can be used when we do not care about the completion status of a task. + /// + public static void IgnoredCallback(Task task) { } + const int MaxTasks = 5000; private struct TaskAction { public string Name; public Task Task; - public Action OnCompletion; - public object UserData; + public Action OnCompletion; + public object? UserData; } private static readonly List taskActions = new List(); - public static void ListTasks() + public static void ListTasks(Action log) { lock (taskActions) { - DebugConsole.NewMessage($"Task count: {taskActions.Count}"); + log($"Task count: {taskActions.Count}"); for (int i = 0; i < taskActions.Count; i++) { - DebugConsole.NewMessage($" -{i}: {taskActions[i].Name}, {taskActions[i].Task.Status}"); + log($" -{i}: {taskActions[i].Name}, {taskActions[i].Task.Status}"); } } } @@ -40,7 +44,7 @@ namespace Barotrauma } } - private static void AddInternal(string name, Task task, Action onCompletion, object userdata, bool addIfFound = true) + private static void AddInternal(string name, Task task, Action onCompletion, object? userdata, bool addIfFound = true) { lock (taskActions) { @@ -55,22 +59,27 @@ namespace Barotrauma ); } taskActions.Add(new TaskAction() { Name = name, Task = task, OnCompletion = onCompletion, UserData = userdata }); - DebugConsole.Log($"New task: {name} ({taskActions.Count}/{MaxTasks})"); } } - public static void Add(string name, Task task, Action onCompletion) + public static Unit Add(string name, Task task, Action? onCompletion) { - AddInternal(name, task, (Task t, object obj) => { onCompletion?.Invoke(t); }, null); - } - public static void AddIfNotFound(string name, Task task, Action onCompletion) - { - AddInternal(name, task, (Task t, object obj) => { onCompletion?.Invoke(t); }, null, addIfFound: false); + AddInternal(name, task, (t, _) => { onCompletion?.Invoke(t); }, null); + return Unit.Value; } - public static void Add(string name, Task task, U userdata, Action onCompletion) where U : class + public static Unit AddWithResult(string name, Task task, Action? onCompletion) where T : notnull { - AddInternal(name, task, (Task t, object obj) => { onCompletion?.Invoke(t, (U)obj); }, userdata); + AddInternal(name, task, (t, _) => + { + if (t.TryGetResult(out T? result)) { onCompletion?.Invoke(result); } + }, null); + return Unit.Value; + } + public static Unit AddIfNotFound(string name, Task task, Action onCompletion) + { + AddInternal(name, task, (t, _) => { onCompletion?.Invoke(t); }, null, addIfFound: false); + return Unit.Value; } public static void Update() @@ -82,7 +91,7 @@ namespace Barotrauma if (taskActions[i].Task.IsCompleted) { taskActions[i].OnCompletion?.Invoke(taskActions[i].Task, taskActions[i].UserData); - DebugConsole.Log($"Task {taskActions[i].Name} completed ({taskActions.Count-1}/{MaxTasks})"); + taskActions[i].Task.Dispose(); taskActions.RemoveAt(i); i--; } @@ -90,12 +99,12 @@ namespace Barotrauma } } - public static void PrintTaskExceptions(Task task, string msg) + public static void PrintTaskExceptions(Task task, string msg, Action throwError) { - DebugConsole.ThrowError(msg); - foreach (Exception e in task.Exception.InnerExceptions) + throwError(msg); + foreach (Exception e in task.Exception?.InnerExceptions ?? Enumerable.Empty()) { - DebugConsole.ThrowError(e.Message + "\n" + e.StackTrace.CleanupStackTrace()); + throwError($"{e.Message}\n{e.StackTrace}"); } } } diff --git a/Barotrauma/BarotraumaShared/SharedSource/Utils/Threading.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Threading.cs similarity index 88% rename from Barotrauma/BarotraumaShared/SharedSource/Utils/Threading.cs rename to Libraries/BarotraumaLibs/BarotraumaCore/Utils/Threading.cs index 25a2ac7fa..a5aa1ad4d 100644 --- a/Barotrauma/BarotraumaShared/SharedSource/Utils/Threading.cs +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Threading.cs @@ -2,7 +2,7 @@ using System.Threading; namespace Barotrauma.Threading { - internal readonly ref struct ReadLock + public readonly ref struct ReadLock { private readonly ReaderWriterLockSlim rwl; public ReadLock(ReaderWriterLockSlim rwl) @@ -17,7 +17,7 @@ namespace Barotrauma.Threading } } - internal readonly ref struct WriteLock + public readonly ref struct WriteLock { private readonly ReaderWriterLockSlim rwl; public WriteLock(ReaderWriterLockSlim rwl) diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/ToolBoxCore.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/ToolBoxCore.cs new file mode 100644 index 000000000..d4c7f54f2 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/ToolBoxCore.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Xna.Framework; + +namespace Barotrauma; + +public static class ToolBoxCore +{ + public static string ByteArrayToHexString(IReadOnlyList ba) + { + var hex = new StringBuilder(ba.Count * 2); + foreach (byte b in ba) + { + hex.AppendFormat("{0:X2}", b); + } + return hex.ToString(); + } + + public static byte[] HexStringToByteArray(string str) + { + var byteRepresentation = new byte[str.Length / 2]; + for (int i = 0; i < byteRepresentation.Length; i++) + { + byteRepresentation[i] = Convert.ToByte(str.Substring(i * 2, 2), 16); + } + + return byteRepresentation; + } + + public static bool IsHexadecimalDigit(this char c) + => char.IsDigit(c) + || c is (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + + public static bool IsHexString(this string s) + => !s.IsNullOrEmpty() && s.All(IsHexadecimalDigit); + + public static UInt32 IdentifierToUint32Hash(Identifier id, MD5 md5) + => StringToUInt32Hash(id.Value.ToLowerInvariant(), md5); + + public static UInt32 StringToUInt32Hash(string str, MD5 md5) + { + //calculate key based on MD5 hash instead of string.GetHashCode + //to ensure consistent results across platforms + byte[] inputBytes = Encoding.UTF8.GetBytes(str); + byte[] hash = md5.ComputeHash(inputBytes); + + UInt32 key = (UInt32)((str.Length & 0xff) << 24); //could use more of the hash here instead? + key |= (UInt32)(hash[hash.Length - 3] << 16); + key |= (UInt32)(hash[hash.Length - 2] << 8); + key |= (UInt32)(hash[hash.Length - 1]); + + return key; + } + + /// + /// Convert a HSV value into a RGB value. + /// + /// Value between 0 and 360 + /// Value between 0 and 1 + /// Value between 0 and 1 + /// Reference + /// + public static Color HSVToRGB(float hue, float saturation, float value) + { + float c = value * saturation; + + float h = Math.Clamp(hue, 0, 360) / 60f; + + float x = c * (1 - Math.Abs(h % 2 - 1)); + + float r = 0, + g = 0, + b = 0; + + if (0 <= h && h <= 1) { r = c; g = x; b = 0; } + else if (1 < h && h <= 2) { r = x; g = c; b = 0; } + else if (2 < h && h <= 3) { r = 0; g = c; b = x; } + else if (3 < h && h <= 4) { r = 0; g = x; b = c; } + else if (4 < h && h <= 5) { r = x; g = 0; b = c; } + else if (5 < h && h <= 6) { r = c; g = 0; b = x; } + + float m = value - c; + + return new Color(r + m, g + m, b + m); + } + + public static Exception GetInnermost(this Exception e) + { + while (e.InnerException != null) { e = e.InnerException; } + + return e; + } +} diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Unit.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Unit.cs new file mode 100644 index 000000000..3a6a77e3b --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/Unit.cs @@ -0,0 +1,8 @@ +namespace Barotrauma; + +/// +/// Unit type, i.e. type with only one possible value. +/// Can be used instead of void to form expressions and +/// fill in generic parameters. +/// +public enum Unit { Value } diff --git a/Libraries/BarotraumaLibs/BarotraumaCore/Utils/UnreachableCodeException.cs b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/UnreachableCodeException.cs new file mode 100644 index 000000000..b84505bc2 --- /dev/null +++ b/Libraries/BarotraumaLibs/BarotraumaCore/Utils/UnreachableCodeException.cs @@ -0,0 +1,8 @@ +using System; + +namespace Barotrauma; + +public sealed class UnreachableCodeException : Exception +{ + public UnreachableCodeException() : base(message: "Code that was supposed to be unreachable was executed.") { } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Achievements/AchievementErrors.cs b/Libraries/BarotraumaLibs/EosInterface/Achievements/AchievementErrors.cs new file mode 100644 index 000000000..9a61d4525 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Achievements/AchievementErrors.cs @@ -0,0 +1,45 @@ +namespace Barotrauma; + +public static partial class EosInterface +{ + public enum AchievementUnlockError + { + Unknown, + InvalidUser, + EosNotInitialized, + TimedOut, + InvalidParameters, + NotFound + } + + public enum IngestStatError + { + Unknown, + InvalidUser, + EosNotInitialized, + TimedOut, + InvalidParameters, + NotFound + } + + public enum QueryStatsError + { + Unknown, + InvalidUser, + EosNotInitialized, + TimedOut, + InvalidParameters, + NotFound + } + + public enum QueryAchievementsError + { + Unknown, + InvalidUser, + InvalidProductUserID, + EosNotInitialized, + TimedOut, + InvalidParameters, + NotFound + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Achievements/Achievements.cs b/Libraries/BarotraumaLibs/EosInterface/Achievements/Achievements.cs new file mode 100644 index 000000000..1506c282a --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Achievements/Achievements.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Achievements + { + private static Implementation? LoadedImplementation => Core.LoadedImplementation; + + public static async Task> UnlockAchievements( + params Identifier[] achievementIds) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.UnlockAchievements(achievementIds) + : Result.Failure(AchievementUnlockError.EosNotInitialized); + + public static async Task> IngestStats( + params (AchievementStat Stat, int IngestAmount)[] stats) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.IngestStats(stats) + : Result.Failure(IngestStatError.EosNotInitialized); + + public static Task, QueryStatsError>> QueryStats( + params AchievementStat[] stats) + => QueryStats(stats.ToImmutableArray()); + + public static async Task, QueryStatsError>> QueryStats( + ImmutableArray stats) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.QueryStats(stats) + : Result.Failure(QueryStatsError.EosNotInitialized); + + public static async Task, QueryAchievementsError>> + QueryPlayerAchievements() + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.QueryPlayerAchievements() + : Result.Failure(QueryAchievementsError.EosNotInitialized); + } + + internal abstract partial class Implementation + { + public abstract Task> UnlockAchievements( + params Identifier[] achievementIds); + + public abstract Task> IngestStats( + params (AchievementStat Stat, int IngestAmount)[] stats); + + public abstract Task, QueryStatsError>> QueryStats( + ImmutableArray stats); + + public abstract Task, QueryAchievementsError>> + QueryPlayerAchievements(); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Core/ApplicationCredentials.cs b/Libraries/BarotraumaLibs/EosInterface/Core/ApplicationCredentials.cs new file mode 100644 index 000000000..c1b52d1d7 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Core/ApplicationCredentials.cs @@ -0,0 +1,10 @@ +namespace Barotrauma; + +public static partial class EosInterface +{ + public enum ApplicationCredentials + { + Client, + Server + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Core/AssemblyInfo.cs b/Libraries/BarotraumaLibs/EosInterface/Core/AssemblyInfo.cs new file mode 100644 index 000000000..cd8161fba --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Core/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("EosInterface.Implementation.Win64")] +[assembly: InternalsVisibleTo("EosInterface.Implementation.MacOS")] +[assembly: InternalsVisibleTo("EosInterface.Implementation.Linux")] \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Core/Core.cs b/Libraries/BarotraumaLibs/EosInterface/Core/Core.cs new file mode 100644 index 000000000..cdd8f8ecb --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Core/Core.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Core + { + internal static Implementation? LoadedImplementation { get; private set; } = null; + private static AssemblyLoadContext? assemblyLoadContext = null; + + private static bool hasShutDown = false; + private static bool failedToInitialize = false; + + private static string GetAssemblyPath(string assemblyName) + => Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + $"{assemblyName}.dll"); + + private static bool resolvingDependency; + + private static Assembly? ResolveDependency(AssemblyLoadContext context, AssemblyName dependencyName) + { + if (resolvingDependency) + { + return null; + } + + resolvingDependency = true; + Assembly dependency = + context.LoadFromAssemblyPath( + GetAssemblyPath(dependencyName.Name ?? throw new Exception("Dependency name was null"))); + resolvingDependency = false; + return dependency; + } + + public enum InitError + { + PlatformInterfaceNotCreated, + AlreadyInitialized, + UnknownOsPlatform, + ImplementationDllLoadFailed, + ImplementationDllHasNoValidClasses, + ImplementationFailedToInstantiate, + NativeDllLoadFailed, + CannotRestartAfterShutdown, + UnhandledErrorCondition + } + + public enum Status + { + NotInitialized, + InitializationError, + ShutDown, + InitializedButOffline, + Online + } + + public static bool IsInitialized + => LoadedImplementation != null && LoadedImplementation.IsInitialized(); + + public static Status CurrentStatus + { + get + { + if (hasShutDown) + { + return Status.ShutDown; + } + + if (failedToInitialize) + { + return Status.InitializationError; + } + + if (LoadedImplementation is { CurrentStatus: var status }) + { + return status; + } + + return Status.NotInitialized; + } + } + + public static Result Init(ApplicationCredentials applicationCredentials, bool enableOverlay) + { + var (success, failure) = Result.GetFactoryMethods(); + if (LoadedImplementation != null) + { + return !LoadedImplementation.IsInitialized() + ? LoadedImplementation.Init(applicationCredentials, enableOverlay) + : failure(InitError.AlreadyInitialized); + } + + if (hasShutDown) + { + return failure(InitError.CannotRestartAfterShutdown); + } + + string platformSuffix; + string nativeDllName; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + platformSuffix = "Win64"; + nativeDllName = "./EOSSDK-Win64-Shipping.dll"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + platformSuffix = "MacOS"; + nativeDllName = "./libEOSSDK-Mac-Shipping.dylib"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + platformSuffix = "Linux"; + nativeDllName = "./libEOSSDK-Linux-Shipping.so"; + } + else + { + failedToInitialize = true; + return failure(InitError.UnknownOsPlatform); + } + + if (!NativeLibrary.TryLoad(nativeDllName, out var nativeLib)) + { + failedToInitialize = true; + return failure(InitError.NativeDllLoadFailed); + } + + NativeLibrary.Free(nativeLib); + + string assemblyName = $"EosInterface.Implementation.{platformSuffix}"; + + assemblyLoadContext = new AssemblyLoadContext(assemblyName, isCollectible: true); + assemblyLoadContext.Resolving += ResolveDependency; + + Assembly implementationAssembly; + try + { + implementationAssembly = assemblyLoadContext.LoadFromAssemblyPath(GetAssemblyPath(assemblyName)); + } + catch + { + failedToInitialize = true; + return failure(InitError.ImplementationDllLoadFailed); + } + + var implementationTypes = + implementationAssembly.DefinedTypes + .Where(t => t.IsSubclassOf(typeof(Implementation))) + .Where(t => t is { IsAbstract: false, IsGenericType: false }) + .ToArray(); + if (!implementationTypes.Any()) + { + failedToInitialize = true; + return failure(InitError.ImplementationDllHasNoValidClasses); + } + + Implementation implementationInstance; + try + { + var implementationInstanceNullable = + (Implementation?)Activator.CreateInstance(implementationTypes.First()); + if (implementationInstanceNullable is null) + { + failedToInitialize = true; + return failure(InitError.ImplementationFailedToInstantiate); + } + + implementationInstance = implementationInstanceNullable; + } + catch + { + failedToInitialize = true; + return failure(InitError.ImplementationFailedToInstantiate); + } + + LoadedImplementation = implementationInstance; + + var initResult = implementationInstance.Init(applicationCredentials, enableOverlay); + if (initResult.IsFailure) + { + failedToInitialize = true; + } + + return initResult; + } + + public enum WillRestartThroughLauncher + { + No, + Yes + } + + public enum CheckForLauncherAndRestartError + { + EosNotInitialized, + UnexpectedError, + UnhandledErrorCondition + } + + public static Result CheckForLauncherAndRestart() + => LoadedImplementation.IsInitialized() + ? LoadedImplementation.CheckForLauncherAndRestart() + : Result.Failure(CheckForLauncherAndRestartError.EosNotInitialized); + + public static void Update() + { + if (LoadedImplementation.IsInitialized()) + { + LoadedImplementation.Update(); + } + } + + public static void CleanupAndQuit() + { + var loadedImplementation = LoadedImplementation; + if (!loadedImplementation.IsInitialized()) + { + return; + } + + TaskPool.Add( + "CleanupAndQuit", + loadedImplementation.CloseAllOwnedSessions(), + _ => QuitNow()); + } + + private static void QuitNow() + { + hasShutDown = CurrentStatus != Status.NotInitialized; + LoadedImplementation?.Quit(); + LoadedImplementation = null; + assemblyLoadContext?.Unload(); + assemblyLoadContext = null; + } + } + + internal abstract partial class Implementation + { + public abstract Core.Status CurrentStatus { get; } + public abstract string NativeLibraryName { get; } + + public abstract Result Init(ApplicationCredentials applicationCredentials, + bool enableOverlay); + + public abstract Result + CheckForLauncherAndRestart(); + + public abstract void Update(); + public abstract void Quit(); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Core/StatusExtensions.cs b/Libraries/BarotraumaLibs/EosInterface/Core/StatusExtensions.cs new file mode 100644 index 000000000..25be83be8 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Core/StatusExtensions.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Barotrauma; + +public static class EosStatusExtensions +{ + public static bool IsInitialized(this EosInterface.Core.Status status) + => status is EosInterface.Core.Status.InitializedButOffline or EosInterface.Core.Status.Online; + + internal static bool IsInitialized( + [NotNullWhen(returnValue: true)] this EosInterface.Implementation? implementation) + => implementation is { CurrentStatus: var status } && status.IsInitialized(); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/EosInterface.csproj b/Libraries/BarotraumaLibs/EosInterface/EosInterface.csproj new file mode 100644 index 000000000..dc10c5393 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/EosInterface.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + disable + enable + Barotrauma + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + true + x64 + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + true + x64 + + + + + + + diff --git a/Libraries/BarotraumaLibs/EosInterface/Friends/EgsFriend.cs b/Libraries/BarotraumaLibs/EosInterface/Friends/EgsFriend.cs new file mode 100644 index 000000000..ae6b6e05a --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Friends/EgsFriend.cs @@ -0,0 +1,13 @@ +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public readonly record struct EgsFriend( + string DisplayName, + EpicAccountId EpicAccountId, + FriendStatus Status, + string ConnectCommand, + string ServerName); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Friends/Friends.cs b/Libraries/BarotraumaLibs/EosInterface/Friends/Friends.cs new file mode 100644 index 000000000..9ef92c6a3 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Friends/Friends.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Friends + { + private static Implementation? LoadedImplementation => Core.LoadedImplementation; + + public enum GetFriendsError + { + EosNotInitialized, + + EgsFriendsQueryTimedOut, + EgsFriendsQueryFailed, + + UserInfoQueryTimedOut, + UserInfoQueryFailed, + CopyUserInfoFailed, + DisplayNameIsEmpty, + + EgsPresenceQueryTimedOut, + EgsPresenceQueryFailed, + CopyPresenceFailed, + + UnhandledErrorCondition + } + + public static async Task> GetFriend( + EpicAccountId selfEaid, + EpicAccountId friendEaid) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.GetFriend(selfEaid, friendEaid) + : Result.Failure(GetFriendsError.EosNotInitialized); + + public static async Task, GetFriendsError>> GetFriends( + EpicAccountId epicAccountId) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.GetFriends(epicAccountId) + : Result.Failure(GetFriendsError.EosNotInitialized); + + public static async Task> GetSelfUserInfo(EpicAccountId epicAccountId) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.GetSelfUserInfo(epicAccountId) + : Result.Failure(GetFriendsError.EosNotInitialized); + } + + internal abstract partial class Implementation + { + public abstract Task> GetFriend( + EpicAccountId selfEaid, + EpicAccountId friendEaid); + + public abstract Task, Friends.GetFriendsError>> GetFriends( + EpicAccountId epicAccountId); + + public abstract Task> GetSelfUserInfo(EpicAccountId epicAccountId); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Friends/Presence.cs b/Libraries/BarotraumaLibs/EosInterface/Friends/Presence.cs new file mode 100644 index 000000000..9b794cf27 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Friends/Presence.cs @@ -0,0 +1,105 @@ +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Presence + { + public readonly record struct JoinGameInfo( + EpicAccountId RecipientId, + string JoinCommand); + + public readonly record struct AcceptInviteInfo( + EpicAccountId RecipientId, + string JoinCommand); + + public readonly record struct ReceiveInviteInfo( + EpicAccountId RecipientId, + EpicAccountId SenderId, + string JoinCommand); + + private static readonly NamedEvent dummyJoinGameEvent = + new NamedEvent(); + + private static readonly NamedEvent dummyAcceptInviteEvent = + new NamedEvent(); + + private static readonly NamedEvent dummyReceiveInviteEvent = + new NamedEvent(); + + public static NamedEvent OnJoinGame + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.OnJoinGame + : dummyJoinGameEvent; + + public static NamedEvent OnInviteAccepted + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.OnInviteAccepted + : dummyAcceptInviteEvent; + + public static NamedEvent OnInviteReceived + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.OnInviteReceived + : dummyReceiveInviteEvent; + + public enum SetJoinCommandError + { + EosNotInitialized, + FailedToSetCustomInvite, + FailedToCreatePresenceModification, + JoinCommandTooLong, + ServerNameTooLong, + FailedToSetJoinInfo, + FailedToGetPuid, + DescTooLong, + FailedToSetRichText, + FailedToSetRecords, + SetPresenceTimedOut, + FailedToSetPresence + } + + public static async Task> SetJoinCommand( + EpicAccountId epicAccountId, string desc, string serverName, string joinCommand) + => Core.LoadedImplementation.IsInitialized() + ? await Core.LoadedImplementation.SetJoinCommand(epicAccountId, desc, serverName, joinCommand) + : Result.Failure(SetJoinCommandError.EosNotInitialized); + + public enum SendInviteError + { + EosNotInitialized, + FailedToGetSelfPuid, + FailedToGetRemotePuid, + TimedOut, + InternalError + } + + public static async Task> SendInvite( + EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId) + => Core.LoadedImplementation.IsInitialized() + ? await Core.LoadedImplementation.SendInvite(selfEpicAccountId, remoteEpicAccountId) + : Result.Failure(SendInviteError.EosNotInitialized); + + public static void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId) + { + if (Core.LoadedImplementation.IsInitialized()) + { + Core.LoadedImplementation.DeclineInvite(selfEpicAccountId, senderEpicAccountId); + } + } + } + + internal abstract partial class Implementation + { + public abstract NamedEvent OnJoinGame { get; } + public abstract NamedEvent OnInviteAccepted { get; } + public abstract NamedEvent OnInviteReceived { get; } + + public abstract Task> SetJoinCommand( + EpicAccountId epicAccountId, string desc, string joinCommand, string s); + public abstract Task> SendInvite( + EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId); + public abstract void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsAuthContinuanceToken.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsAuthContinuanceToken.cs new file mode 100644 index 000000000..bd8e6f8cc --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsAuthContinuanceToken.cs @@ -0,0 +1,34 @@ +using System; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public sealed class EgsAuthContinuanceToken + { + // Got this number by checking a decoded continuance token, may be subject to change + public static readonly TimeSpan Duration = TimeSpan.FromMinutes(30); + + public readonly DateTime ExpiryTime; + public bool IsValid => value != IntPtr.Zero && DateTime.Now < ExpiryTime; + + private IntPtr value; + + public EgsAuthContinuanceToken(IntPtr value, DateTime expiryTime) + { + this.value = value; + ExpiryTime = expiryTime; + } + + public IntPtr Spend() + { + var retVal = IsValid ? value : IntPtr.Zero; + value = IntPtr.Zero; + return retVal; + } + + public override string ToString() + => $"{(IsValid ? "Valid" : "Invalid")} EGS ContinuanceToken" + + (IsValid ? $" (expires on {ExpiryTime})" : ""); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsIdToken.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsIdToken.cs new file mode 100644 index 000000000..e03b32fe1 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EgsIdToken.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public enum GetEgsSelfIdTokenError + { + EosNotInitialized, + NotLoggedIn, + InvalidToken, + UnhandledErrorCondition + } + + public enum VerifyEgsIdTokenResult + { + Verified, + Failed + } + + /// + /// Represents an Epic Games ID Token, used to authenticate an Epic Account ID. + /// This is distinct from , which represents an EOS ID Token. + /// + public abstract class EgsIdToken + { + public abstract EpicAccountId AccountId { get; } + + public static Option Parse(string str) + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.ParseEgsIdToken(str) + : Option.None; + + public static Result FromEpicAccountId(EpicAccountId accountId) + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.GetEgsIdTokenForEpicAccountId(accountId) + : Result.Failure(GetEgsSelfIdTokenError.EosNotInitialized); + + public abstract override string ToString(); + + public abstract Task Verify(AccountId accountId); + } + + internal abstract partial class Implementation + { + public abstract Option ParseEgsIdToken(string str); + + public abstract Result GetEgsIdTokenForEpicAccountId( + EpicAccountId accountId); + } +} diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosConnectContinuanceToken.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosConnectContinuanceToken.cs new file mode 100644 index 000000000..953986f29 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosConnectContinuanceToken.cs @@ -0,0 +1,37 @@ +using System; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public sealed class EosConnectContinuanceToken + { + // Got this number by checking a decoded continuance token, may be subject to change + public static readonly TimeSpan Duration = TimeSpan.FromMinutes(30); + + public readonly AccountId ExternalAccountId; + public readonly DateTime ExpiryTime; + public bool IsValid => value != IntPtr.Zero && DateTime.Now < ExpiryTime; + + private IntPtr value; + + public EosConnectContinuanceToken(IntPtr value, AccountId externalAccountId, DateTime expiryTime) + { + this.value = value; + this.ExternalAccountId = externalAccountId; + ExpiryTime = expiryTime; + } + + public IntPtr Spend() + { + var retVal = IsValid ? value : IntPtr.Zero; + value = IntPtr.Zero; + return retVal; + } + + public override string ToString() + => $"{(IsValid ? "Valid" : "Invalid")} {ExternalAccountId} ContinuanceToken" + + (IsValid ? $" (expires on {ExpiryTime})" : ""); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosIdToken.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosIdToken.cs new file mode 100644 index 000000000..9cb490123 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/EosIdToken.cs @@ -0,0 +1,114 @@ +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public enum GetEosSelfIdTokenError + { + EosNotInitialized, + NotLoggedIn, + InvalidToken, + CouldNotParseJwt, + UnhandledErrorCondition + } + + public enum VerifyEosIdTokenError + { + EosNotInitialized, + TimedOut, + ProductIdDidNotMatch, + CouldNotParseExternalAccountId, + UnhandledErrorCondition + } + + /// + /// Represents an EOS ID Token, used to authenticate a Product User ID. + /// This is distinct from , which represents an Epic Games ID Token. + /// + public readonly record struct EosIdToken( + ProductUserId ProductUserId, + JsonWebToken JsonWebToken) + { + public async Task> Verify() + => Core.LoadedImplementation is { } loadedImplementation + ? await loadedImplementation.VerifyEosIdToken(this) + : Result.Failure(VerifyEosIdTokenError.EosNotInitialized); + + public static Option Parse(string str) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes(str)); + JsonDocument? jsonDoc = null; + try + { + if (!JsonDocument.TryParseValue(ref jsonReader, out jsonDoc)) + { + return Option.None; + } + + if (!jsonDoc.RootElement.TryGetProperty(nameof(ProductUserId), out var puidElement)) + { + return Option.None; + } + + if (!jsonDoc.RootElement.TryGetProperty(nameof(JsonWebToken), out var jwtElement)) + { + return Option.None; + } + + var puidStr = puidElement.ToString(); + if (!puidStr.IsHexString()) + { + return Option.None; + } + + var puid = new ProductUserId(puidStr); + + var jwtStr = jwtElement.ToString(); + if (!JsonWebToken.Parse(jwtStr).TryUnwrap(out var jsonWebToken)) + { + return Option.None; + } + + var newToken = new EosIdToken(puid, jsonWebToken); + + return Option.Some(newToken); + } + catch + { + return Option.None; + } + finally + { + jsonDoc?.Dispose(); + } + } + + public static Result FromProductUserId(ProductUserId puid) + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.GetEosIdTokenForProductUserId(puid) + : Result.Failure(GetEosSelfIdTokenError.EosNotInitialized); + + public override string ToString() + { + using var memoryStream = new System.IO.MemoryStream(); + using var jsonWriter = new Utf8JsonWriter(memoryStream); + jsonWriter.WriteStartObject(); + jsonWriter.WriteString(nameof(ProductUserId), ProductUserId.Value); + jsonWriter.WriteString(nameof(JsonWebToken), JsonWebToken.ToString()); + jsonWriter.WriteEndObject(); + jsonWriter.Flush(); + memoryStream.Flush(); + return Encoding.UTF8.GetString(memoryStream.ToArray()); + } + } + + internal abstract partial class Implementation + { + public abstract Task> VerifyEosIdToken(EosIdToken token); + public abstract Result GetEosIdTokenForProductUserId(ProductUserId puid); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/IdQueries.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/IdQueries.cs new file mode 100644 index 000000000..fd0195a04 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/IdQueries.cs @@ -0,0 +1,64 @@ +using System.Collections.Immutable; +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class IdQueries + { + private static Implementation? LoadedImplementation => Core.LoadedImplementation; + + public static bool IsLoggedIntoEosConnect + => GetLoggedInPuids() is { Length: > 0 }; + + /// + /// Gets all of the s the player has logged in with. + /// For most players, this is expected to return one ID. + /// It may return two IDs if a Steam user has chosen to link their account to an Epic Account. + /// + public static ImmutableArray GetLoggedInPuids() + => LoadedImplementation.IsInitialized() + ? LoadedImplementation.GetLoggedInPuids() + : ImmutableArray.Empty; + + /// + /// Gets all of the s the player has logged in with. + /// This is expected to return at most one ID. + ///

+ /// This should return exactly one ID for any Epic Games Store player. + ///
+ /// Steam players may choose to link their account to only one Epic Games account. + ///
+ public static ImmutableArray GetLoggedInEpicIds() + => LoadedImplementation.IsInitialized() + ? LoadedImplementation.GetLoggedInEpicIds() + : ImmutableArray.Empty; + + public enum GetSelfExternalIdError + { + EosNotInitialized, + Inaccessible, + Timeout, + InvalidUser, + ParseError, + UnhandledErrorCondition + } + + public static async Task, GetSelfExternalIdError>> GetSelfExternalAccountIds( + ProductUserId puid) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.GetSelfExternalAccountIds(puid) + : Result.Failure(GetSelfExternalIdError.EosNotInitialized); + } + + internal abstract partial class Implementation + { + public abstract ImmutableArray GetLoggedInPuids(); + public abstract ImmutableArray GetLoggedInEpicIds(); + + public abstract Task, IdQueries.GetSelfExternalIdError>> + GetSelfExternalAccountIds(ProductUserId puid); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Login.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Login.cs new file mode 100644 index 000000000..df10f35e6 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Login.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Login + { + private static Implementation? LoadedImplementation => Core.LoadedImplementation; + + public enum CreateProductAccountError + { + EosNotInitialized, + InvalidContinuanceToken, + Timeout, + UnhandledErrorCondition + } + + public static async Task> CreateProductAccount( + EosConnectContinuanceToken eosContinuanceToken) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.CreateProductAccount(eosContinuanceToken) + : Result.Failure(CreateProductAccountError.EosNotInitialized); + + public enum LinkExternalAccountError + { + EosNotInitialized, + InvalidContinuanceToken, + Timeout, + CannotLink, + UnhandledErrorCondition + } + + public static async Task> LinkExternalAccount(ProductUserId puid, + EosConnectContinuanceToken eosContinuanceToken) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LinkExternalAccount(puid, eosContinuanceToken) + : Result.Failure(LinkExternalAccountError.EosNotInitialized); + + public enum UnlinkExternalAccountError + { + EosNotInitialized, + FailedToGetExternalAccounts, + NotLoggedInToGivenAccount, + Timeout, + CannotLink, + InvalidUser, + UnhandledErrorCondition + } + + public static async Task> UnlinkExternalAccount(ProductUserId puid) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.UnlinkExternalAccount(puid) + : Result.Failure(UnlinkExternalAccountError.EosNotInitialized); + + public enum LoginError + { + EosNotInitialized, + + SteamNotLoggedIn, + FailedToGetSteamSessionTicket, + + EgsLoginTimeout, + EgsAccountNotFound, + FailedToParseEgsId, + FailedToGetEgsIdToken, + AuthExchangeCodeNotFound, + AuthRequiresOpeningBrowser, + + Timeout, + InvalidUser, + EgsAccessDenied, + EosAccessDenied, + UnexpectedContinuanceToken, + + UnhandledFailureCondition + } + + [Flags] + public enum LoginEpicFlags + { + None = 0x0, + FailWithoutOpeningBrowser = 0x1 + } + + public static async + Task, LoginError>> + LoginEpicWithLinkedSteamAccount(LoginEpicFlags flags) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LoginEpicWithLinkedSteamAccount(flags) + : Result.Failure(LoginError.EosNotInitialized); + + public static async Task, LoginError>> + LoginEpicExchangeCode(string exchangeCode) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LoginEpicExchangeCode(exchangeCode) + : Result.Failure(LoginError.EosNotInitialized); + + public static async Task, LoginError>> + LoginEpicIdToken(EgsIdToken token) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LoginEpicIdToken(token) + : Result.Failure(LoginError.EosNotInitialized); + + public static async Task, LoginError>> LoginSteam() + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LoginSteam() + : Result.Failure(LoginError.EosNotInitialized); + + public enum LinkExternalAccountToEpicAccountError + { + EosNotInitialized, + + TimedOut, + FailedToParseEgsAccountId, + + UnhandledErrorCondition + } + + public static async Task> + LinkExternalAccountToEpicAccount(EgsAuthContinuanceToken continuanceToken) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LinkExternalAccountToEpicAccount(continuanceToken) + : Result.Failure(LinkExternalAccountToEpicAccountError.EosNotInitialized); + + public enum LogoutEpicAccountError + { + EosNotInitialized, + TimedOut, + UnhandledErrorCondition + } + + public static async Task> LogoutEpicAccount(EpicAccountId egsId) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.LogoutEpicAccount(egsId) + : Result.Failure(LogoutEpicAccountError.EosNotInitialized); + + /// + /// This is essentially a function for logging out, except EOS has no EOS_Connect_Logout function + /// so instead we have this to fake it. Once you use this, no methods should return this PUID + /// until you log into it again. + /// + public static void MarkAsInaccessible(ProductUserId puid) + { + if (LoadedImplementation.IsInitialized()) + { + LoadedImplementation.MarkAsInaccessible(puid); + } + } + + public static Option ParseEgsExchangeCode(IReadOnlyList args) + { + if (args.Contains("-AUTH_TYPE=exchangecode", StringComparer.OrdinalIgnoreCase)) + { + return args.FirstOrNone(arg => + arg.StartsWith("-AUTH_PASSWORD=", StringComparison.OrdinalIgnoreCase)) + .Select(arg => arg["-AUTH_PASSWORD=".Length..]); + } + + return Option.None; + } + + public static void TestEosSessionTimeoutRecovery(ProductUserId puid) + { + if (LoadedImplementation.IsInitialized()) + { + LoadedImplementation.TestEosSessionTimeoutRecovery(puid); + } + } + } + + internal abstract partial class Implementation + { + public abstract Task> CreateProductAccount( + EosConnectContinuanceToken eosContinuanceToken); + + public abstract Task> LinkExternalAccount(ProductUserId puid, + EosConnectContinuanceToken eosContinuanceToken); + + public abstract Task> UnlinkExternalAccount(ProductUserId puid); + + public abstract Task, Login.LoginError>> + LoginEpicExchangeCode(string exchangeCode); + + public abstract + Task, Login.LoginError>> + LoginEpicWithLinkedSteamAccount(Login.LoginEpicFlags flags); + + public abstract Task, Login.LoginError>> + LoginEpicIdToken(EgsIdToken token); + + public abstract Task, Login.LoginError>> LoginSteam(); + + public abstract Task> + LinkExternalAccountToEpicAccount(EgsAuthContinuanceToken continuanceToken); + + public abstract Task> LogoutEpicAccount(EpicAccountId egsId); + public abstract void MarkAsInaccessible(ProductUserId puid); + public abstract void TestEosSessionTimeoutRecovery(ProductUserId puid); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Ownership.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Ownership.cs new file mode 100644 index 000000000..905198d53 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/Ownership.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Ownership + { + private static Implementation? LoadedImplementation => Core.LoadedImplementation; + + public static async Task> GetGameOwnershipToken(EpicAccountId selfEpicAccountId) + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.GetGameOwnershipToken(selfEpicAccountId) + : Option.None; + + public readonly record struct Token(JsonWebToken Jwt) + { + public async Task> Verify() + => LoadedImplementation.IsInitialized() + ? await LoadedImplementation.VerifyGameOwnershipToken(this) + : Option.None; + } + } + + internal abstract partial class Implementation + { + public abstract Task> GetGameOwnershipToken(EpicAccountId selfEpicAccountId); + + public abstract Task> VerifyGameOwnershipToken(Ownership.Token token); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/ProductUserId.cs b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/ProductUserId.cs new file mode 100644 index 000000000..49bacb800 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/IdAndAuth/ProductUserId.cs @@ -0,0 +1,13 @@ +namespace Barotrauma; + +public static partial class EosInterface +{ + /// + /// A Product User ID is an EOS-specific ID that's linked to the SteamID or the Epic Account ID of a player. + /// It is used to identify players in many of EOS' interfaces, most notably the P2P networking interface. + ///

+ /// A Product User ID used by Barotrauma is only valid for Barotrauma; other games that use EOS get their + /// own separate set of Product User IDs. + ///
+ public readonly record struct ProductUserId(string Value); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/P2P/P2PSocket.cs b/Libraries/BarotraumaLibs/EosInterface/P2P/P2PSocket.cs new file mode 100644 index 000000000..823689f73 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/P2P/P2PSocket.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using Barotrauma.Networking; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public abstract class P2PSocket : IDisposable + { + public enum CreationError + { + EosNotInitialized, + UserNotLoggedIn, + RequestBindFailed, + CloseBindFailed + } + + public readonly record struct IncomingConnectionRequest( + P2PSocket Socket, + ProductUserId RemoteUserId) + { + public void Accept() + => Socket.AcceptConnectionRequest(this); + } + + public readonly record struct RemoteConnectionClosed( + ProductUserId RemoteUserId, + RemoteConnectionClosed.ConnectionClosedReason Reason) + { + public enum ConnectionClosedReason + { + Unknown, + ClosedByLocalUser, + ClosedByPeer, + TimedOut, + TooManyConnections, + InvalidMessage, + InvalidData, + ConnectionFailed, + ConnectionClosed, + NegotiationFailed, + UnexpectedError, + Unhandled + } + } + + public readonly NamedEvent HandleIncomingConnection + = new NamedEvent(); + + public readonly NamedEvent HandleClosedConnection + = new NamedEvent(); + + public static Result Create(ProductUserId puid, SocketId socketId) + => Core.LoadedImplementation.IsInitialized() + ? Core.LoadedImplementation.CreateP2PSocket(puid, socketId) + : Result.Failure(CreationError.EosNotInitialized); + + public abstract void AcceptConnectionRequest(IncomingConnectionRequest request); + + public abstract void CloseConnection(ProductUserId remoteUserId); + + public readonly record struct IncomingMessage( + byte[] Buffer, + int ByteLength, + ProductUserId Sender); + + public abstract IEnumerable GetMessageBatch(); + + public readonly record struct OutgoingMessage( + byte[] Buffer, + int ByteLength, + ProductUserId Destination, + DeliveryMethod DeliveryMethod); + + public enum SendError + { + EosNotInitialized, + InvalidParameters, + LimitExceeded, + NoConnection, + UnhandledErrorCondition + } + + public abstract Result SendMessage(OutgoingMessage msg); + + public abstract void Dispose(); + } + + internal abstract partial class Implementation + { + public abstract Result CreateP2PSocket(ProductUserId puid, + SocketId socketId); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/P2P/SocketId.cs b/Libraries/BarotraumaLibs/EosInterface/P2P/SocketId.cs new file mode 100644 index 000000000..ec11c490b --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/P2P/SocketId.cs @@ -0,0 +1,6 @@ +namespace Barotrauma; + +public static partial class EosInterface +{ + public readonly record struct SocketId(string SocketName); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterface/Sessions/Sessions.cs b/Libraries/BarotraumaLibs/EosInterface/Sessions/Sessions.cs new file mode 100644 index 000000000..3b01d1aae --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterface/Sessions/Sessions.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Barotrauma; + +public static partial class EosInterface +{ + public static class Sessions + { + public const string DefaultBucketName = "BBucket"; + public const int MinBucketIndex = 0; + public const int MaxBucketIndex = 9; + + public sealed record OwnedSession( + string BucketId, + Identifier InternalId, + Identifier GlobalId, + Dictionary Attributes) : IDisposable + { + public Option HostAddress = Option.None; + + public ImmutableDictionary SyncedAttributes = + ImmutableDictionary.Empty; + + public async Task> UpdateAttributes() + => Core.LoadedImplementation is { } implementation + ? await implementation.UpdateOwnedSessionAttributes(this) + : Result.Failure(AttributeUpdateError.EosNotInitialized); + + public async Task> Close() + => Core.LoadedImplementation is { } implementation + ? await implementation.CloseOwnedSession(this) + : Result.Failure(CloseError.EosNotInitialized); + + public void Dispose() + { + if (!Core.IsInitialized) + { + return; + } + + var _ = Close(); + } + } + + public readonly record struct RemoteSession( + string SessionId, + string HostAddress, + int CurrentPlayers, + int MaxPlayers, + ImmutableDictionary Attributes, + string BucketId) + { + public readonly record struct Query( + int BucketIndex, + ProductUserId LocalUserId, + uint MaxResults, + ImmutableDictionary Attributes) + { + public enum Error + { + EosNotInitialized, + + ExceededMaxAllowedResults, + + InvalidParameters, + TimedOut, + NotFound, + + UnhandledErrorCondition + } + + public async Task, Error>> Run() + => Core.LoadedImplementation is { } loadedImplementation + ? await loadedImplementation.RunRemoteSessionQuery(this) + : Result.Failure(Error.EosNotInitialized); + } + } + + public enum CreateError + { + EosNotInitialized, + TimedOut, + + SessionAlreadyExists, + + InvalidParametersForAddAttribute, + IncompatibleVersionForAddAttribute, + UnhandledErrorConditionForAddAttribute, + + InvalidUser, + + UnhandledErrorCondition + } + + public enum AttributeUpdateError + { + EosNotInitialized, + TimedOut, + + FailedToCreateSessionModificationHandle, + + InvalidParametersForRemoveAttribute, + IncompatibleVersionForRemoveAttribute, + UnhandledErrorConditionForRemoveAttribute, + + InvalidParametersForAddAttribute, + IncompatibleVersionForAddAttribute, + UnhandledErrorConditionForAddAttribute, + + InvalidParametersForSessionUpdate, + SessionsOutOfSync, + SessionNotFound, + NoConnection, + + UnhandledErrorCondition + } + + public enum CloseError + { + EosNotInitialized, + TimedOut, + + InvalidParameters, + AlreadyPending, + NotFound, + UnhandledErrorCondition + } + + public enum RegisterError + { + EosNotInitialized, + TimedOut, + UnhandledErrorCondition + } + + public enum UnregisterError + { + EosNotInitialized, + TimedOut, + UnhandledErrorCondition + } + + public static async Task> CreateSession(Option puidOption, + Identifier internalId, int maxPlayers) + => Core.LoadedImplementation.IsInitialized() + ? await Core.LoadedImplementation.CreateSession(puidOption, internalId, maxPlayers) + : Result.Failure(CreateError.EosNotInitialized); + } + + internal abstract partial class Implementation + { + public abstract Task> CreateSession( + Option selfUserIdOption, Identifier internalId, int maxPlayers); + + public abstract Task> UpdateOwnedSessionAttributes( + Sessions.OwnedSession session); + + public abstract Task> CloseOwnedSession(Sessions.OwnedSession session); + public abstract Task CloseAllOwnedSessions(); + + public abstract Task, Sessions.RemoteSession.Query.Error>> + RunRemoteSessionQuery(Sessions.RemoteSession.Query query); + } +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/.gitignore b/Libraries/BarotraumaLibs/EosInterfacePrivate/.gitignore new file mode 100644 index 000000000..c8cdfedfb --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/.gitignore @@ -0,0 +1,2 @@ +EOS-SDK/* +**/ExcludeFromPublicRepo/* diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Linux.csproj b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Linux.csproj new file mode 100644 index 000000000..57341009e --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Linux.csproj @@ -0,0 +1,40 @@ + + + + EosInterface.Implementation.Linux + net6.0 + EosInterfacePrivate + false + disable + EOS_PLATFORM_LINUX + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + + + + + + PreserveNewest + libEOSSDK-Linux-Shipping.so + + + + + + + + + + + diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.MacOS.csproj b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.MacOS.csproj new file mode 100644 index 000000000..df8cbade0 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.MacOS.csproj @@ -0,0 +1,40 @@ + + + + EosInterface.Implementation.MacOS + net6.0 + EosInterfacePrivate + false + disable + EOS_PLATFORM_OSX + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + + + + + + PreserveNewest + libEOSSDK-Mac-Shipping.dylib + + + + + + + + + + + diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Win64.csproj b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Win64.csproj new file mode 100644 index 000000000..7af67291b --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/EosInterface.Implementation.Win64.csproj @@ -0,0 +1,39 @@ + + + + EosInterface.Implementation.Win64 + net6.0 + EosInterfacePrivate + false + disable + EOS_PLATFORM_WINDOWS_64 + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + ;NU1605;CS0114;CS0108;CS8597;CS8600;CS8601;CS8602;CS8603;CS8604;CS8605;CS8606;CS8607;CS8608;CS8609;CS8610;CS8611;CS8612;CS8613;CS8614;CS8615;CS8616;CS8617;CS8618;CS8619;CS8620;CS8621;CS8622;CS8624;CS8625;CS8626;CS8629;CS8631;CS8632;CS8633;CS8634;CS8638;CS8643;CS8644;CS8645;CS8653;CS8654;CS8655;CS8667;CS8669;CS8670;CS8714;CS8717;CS8765 + x64 + + + + + + + + PreserveNewest + EOSSDK-Win64-Shipping.dll + + + + + + + + + + + diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Achievements/AchievementsPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Achievements/AchievementsPrivate.cs new file mode 100644 index 000000000..2d8149461 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Achievements/AchievementsPrivate.cs @@ -0,0 +1,266 @@ +#nullable enable + +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma; + +namespace EosInterfacePrivate; + +public static class AchievementsPrivate +{ + public static async Task> UnlockAchievements(params Identifier[] achievements) + { + if (CorePrivate.AchievementsInterface is not { } achievementsInterface) { return Result.Failure(EosInterface.AchievementUnlockError.EosNotInitialized); } + + var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids(); + + if (loggedInUsers is not { Length: > 0 }) + { + return Result.Failure(EosInterface.AchievementUnlockError.InvalidUser); + } + var loggedInUser = loggedInUsers[0]; + + var achievementUnlockWaiter = new CallbackWaiter(); + var options = new Epic.OnlineServices.Achievements.UnlockAchievementsOptions + { + AchievementIds = achievements.Select(static i => new Epic.OnlineServices.Utf8String(i.Value.ToLowerInvariant())).ToArray(), + UserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value) + }; + + achievementsInterface.UnlockAchievements(options: ref options, clientData: null, completionDelegate: achievementUnlockWaiter.OnCompletion); + var resultOption = await achievementUnlockWaiter.Task; + + if (!resultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.AchievementUnlockError.TimedOut); + } + + return callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.Success => Result.Success(callbackResult.AchievementsCount), + Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.AchievementUnlockError.InvalidParameters), + Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.AchievementUnlockError.InvalidUser), + Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.AchievementUnlockError.NotFound), + var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.AchievementUnlockError.Unknown)) + }; + } + + public static async Task, EosInterface.QueryStatsError>> QueryStats(ImmutableArray stats) + { + if (CorePrivate.StatsInterface is not { } statsInterface) { return Result.Failure(EosInterface.QueryStatsError.EosNotInitialized); } + + var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids(); + + if (loggedInUsers is not { Length: > 0 }) + { + return Result.Failure(EosInterface.QueryStatsError.InvalidUser); + } + var loggedInUser = loggedInUsers[0]; + + var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value); + + var options = new Epic.OnlineServices.Stats.QueryStatsOptions + { + LocalUserId = convertedUserId, + TargetUserId = convertedUserId, + StatNames = stats.Any() + ? stats.Select(static s => new Epic.OnlineServices.Utf8String(s.ToIdentifier().Value.ToLowerInvariant())).ToArray() + : default + }; + + var queryWaiter = new CallbackWaiter(); + statsInterface.QueryStats(options: ref options, clientData: null, completionDelegate: queryWaiter.OnCompletion); + + var resultOption = await queryWaiter.Task; + + if (!resultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.QueryStatsError.TimedOut); + } + + if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.QueryStatsError.InvalidParameters), + Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.QueryStatsError.InvalidUser), + Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.QueryStatsError.NotFound), + var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.QueryStatsError.Unknown)) + }; + } + + var builder = ImmutableDictionary.CreateBuilder(); + + if (stats.Length is 0) + { + var countOptions = new Epic.OnlineServices.Stats.GetStatCountOptions + { + TargetUserId = convertedUserId + }; + uint count = statsInterface.GetStatsCount(ref countOptions); + + for (uint i = 0; i < count; i++) + { + var copyIndexOptions = new Epic.OnlineServices.Stats.CopyStatByIndexOptions + { + TargetUserId = convertedUserId, + StatIndex = i + }; + var copyResult = statsInterface.CopyStatByIndex(ref copyIndexOptions, out var statOut); + + if (copyResult is Epic.OnlineServices.Result.Success && statOut is { Name: var name, Value: var value }) + { + builder.Add(AchievementStatExtension.FromIdentifier(new Identifier(name)), value); + } + } + } + else + { + foreach (AchievementStat stat in stats) + { + var copyOptions = new Epic.OnlineServices.Stats.CopyStatByNameOptions + { + TargetUserId = convertedUserId, + Name = new Epic.OnlineServices.Utf8String(stat.ToString().ToLowerInvariant()) + }; + var copyResult = statsInterface.CopyStatByName(ref copyOptions, out var statOut); + + if (copyResult is Epic.OnlineServices.Result.Success && statOut is { Name: var name, Value: var value }) + { + builder.Add(AchievementStatExtension.FromIdentifier(new Identifier(name)), value); + } + } + } + + return Result.Success(builder.ToImmutable()); + } + + public static async Task, EosInterface.QueryAchievementsError>> QueryPlayerAchievements() + { + if (CorePrivate.AchievementsInterface is not { } achievementsInterface) { return Result.Failure(EosInterface.QueryAchievementsError.EosNotInitialized); } + + var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids(); + + if (loggedInUsers is not { Length: > 0 }) + { + return Result.Failure(EosInterface.QueryAchievementsError.InvalidUser); + } + var loggedInUser = loggedInUsers[0]; + + var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value); + + var options = new Epic.OnlineServices.Achievements.QueryPlayerAchievementsOptions + { + LocalUserId = convertedUserId, + TargetUserId = convertedUserId + }; + + var queryWaiter = new CallbackWaiter(); + achievementsInterface.QueryPlayerAchievements(options: ref options, clientData: null, completionDelegate: queryWaiter.OnCompletion); + + var resultOption = await queryWaiter.Task; + + if (!resultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.QueryAchievementsError.TimedOut); + } + + if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.QueryAchievementsError.InvalidParameters), + Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.QueryAchievementsError.InvalidUser), + Epic.OnlineServices.Result.InvalidProductUserID => Result.Failure(EosInterface.QueryAchievementsError.InvalidProductUserID), + Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.QueryAchievementsError.NotFound), + var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.QueryAchievementsError.Unknown)) + }; + } + + var countOptions = new Epic.OnlineServices.Achievements.GetPlayerAchievementCountOptions + { + UserId = convertedUserId + }; + uint count = achievementsInterface.GetPlayerAchievementCount(ref countOptions); + + var builder = ImmutableDictionary.CreateBuilder(); + for (uint i = 0; i < count; i++) + { + var copyIndexOptions = new Epic.OnlineServices.Achievements.CopyPlayerAchievementByIndexOptions + { + TargetUserId = convertedUserId, + LocalUserId = convertedUserId, + AchievementIndex = i + }; + var copyResult = achievementsInterface.CopyPlayerAchievementByIndex(ref copyIndexOptions, out var achievementOut); + + if (copyResult is Epic.OnlineServices.Result.Success && achievementOut is { AchievementId: var name, Progress: var value }) + { + builder.Add(new Identifier(name), value); + } + } + + return Result.Success(builder.ToImmutable()); + } + + public static async Task> IngestStats(params (AchievementStat Stat, int IngestAmount)[] stats) + { + if (CorePrivate.StatsInterface is not { } statsInterface) { return Result.Failure(EosInterface.IngestStatError.EosNotInitialized); } + + var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids(); + + if (loggedInUsers is not { Length: > 0 }) + { + return Result.Failure(EosInterface.IngestStatError.InvalidUser); + } + var loggedInUser = loggedInUsers[0]; + + var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value); + + var options = new Epic.OnlineServices.Stats.IngestStatOptions + { + LocalUserId = convertedUserId, + TargetUserId = convertedUserId, + Stats = stats.Select(static s => new Epic.OnlineServices.Stats.IngestData + { + StatName = s.Stat.ToString().ToLowerInvariant(), + IngestAmount = s.IngestAmount + }).ToArray() + }; + + var ingestStatWaiter = new CallbackWaiter(); + statsInterface.IngestStat(options: ref options, clientData: null, completionDelegate: ingestStatWaiter.OnCompletion); + + var resultOption = await ingestStatWaiter.Task; + + if (!resultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.IngestStatError.TimedOut); + } + + return callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.Success => Result.Success(Unit.Value), + Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.IngestStatError.InvalidParameters), + Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.IngestStatError.InvalidUser), + Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.IngestStatError.NotFound), + var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.IngestStatError.Unknown)) + }; + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> UnlockAchievements(params Identifier[] achievementIds) + => TaskScheduler.Schedule(() => AchievementsPrivate.UnlockAchievements(achievementIds)); + + public override Task> IngestStats(params (AchievementStat Stat, int IngestAmount)[] stats) + => TaskScheduler.Schedule(() => AchievementsPrivate.IngestStats(stats)); + + public override Task, EosInterface.QueryStatsError>> QueryStats(ImmutableArray stats) + => TaskScheduler.Schedule(() => AchievementsPrivate.QueryStats(stats)); + + public override Task, EosInterface.QueryAchievementsError>> QueryPlayerAchievements() + => TaskScheduler.Schedule(AchievementsPrivate.QueryPlayerAchievements); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CorePrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CorePrivate.cs new file mode 100644 index 000000000..02f3c854a --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CorePrivate.cs @@ -0,0 +1,176 @@ +#nullable enable +using System; +using Barotrauma.Debugging; +using Microsoft.Xna.Framework; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class CorePrivate +{ + public static EosInterface.Core.Status CurrentStatus + => platformInterface is null + ? EosInterface.Core.Status.NotInitialized + : platformInterface.GetNetworkStatus() == Epic.OnlineServices.Platform.NetworkStatus.Online + ? EosInterface.Core.Status.Online + : EosInterface.Core.Status.InitializedButOffline; + + private static Epic.OnlineServices.Platform.Options platformInterfaceOptions; + public static Epic.OnlineServices.Platform.Options PlatformInterfaceOptions => platformInterfaceOptions; + + private static Epic.OnlineServices.Platform.PlatformInterface? platformInterface; + public static Epic.OnlineServices.Platform.PlatformInterface? PlatformInterface => platformInterface; + + public static Epic.OnlineServices.Connect.ConnectInterface? ConnectInterface + => PlatformInterface?.GetConnectInterface(); + + public static Epic.OnlineServices.Auth.AuthInterface? EgsAuthInterface + => PlatformInterface?.GetAuthInterface(); + + public static Epic.OnlineServices.Friends.FriendsInterface? EgsFriendsInterface + => PlatformInterface?.GetFriendsInterface(); + + public static Epic.OnlineServices.UserInfo.UserInfoInterface? EgsUserInfoInterface + => PlatformInterface?.GetUserInfoInterface(); + + public static Epic.OnlineServices.Presence.PresenceInterface? EgsPresenceInterface + => PlatformInterface?.GetPresenceInterface(); + + public static Epic.OnlineServices.CustomInvites.CustomInvitesInterface? EgsCustomInvitesInterface + => PlatformInterface?.GetCustomInvitesInterface(); + + public static Epic.OnlineServices.UI.UIInterface? EgsUiInterface + => PlatformInterface?.GetUIInterface(); + + public static Epic.OnlineServices.Sessions.SessionsInterface? SessionsInterface + => PlatformInterface?.GetSessionsInterface(); + + public static Epic.OnlineServices.P2P.P2PInterface? P2PInterface + => PlatformInterface?.GetP2PInterface(); + + public static Epic.OnlineServices.Achievements.AchievementsInterface? AchievementsInterface + => PlatformInterface?.GetAchievementsInterface(); + + public static Epic.OnlineServices.Stats.StatsInterface? StatsInterface + => PlatformInterface?.GetStatsInterface(); + + public static Epic.OnlineServices.Ecom.EcomInterface? EcomInterface + => PlatformInterface?.GetEcomInterface(); + + public static Result Init(ImplementationPrivate implementation, EosInterface.ApplicationCredentials applicationCredentials, bool enableOverlay) + { + var initializeOptions = new Epic.OnlineServices.Platform.InitializeOptions + { + ProductName = "Barotrauma", + ProductVersion = GameVersion.CurrentVersion.ToString(), + + SystemInitializeOptions = IntPtr.Zero, + OverrideThreadAffinity = null, + + AllocateMemoryFunction = IntPtr.Zero, + ReallocateMemoryFunction = IntPtr.Zero, + ReleaseMemoryFunction = IntPtr.Zero + }; + + var result = Epic.OnlineServices.Platform.PlatformInterface.Initialize(ref initializeOptions); + Console.WriteLine( + $"{nameof(Epic.OnlineServices.Platform.PlatformInterface)}.{nameof(Epic.OnlineServices.Platform.PlatformInterface.Initialize)} result: {result}"); + + platformInterfaceOptions = PlatformInterfaceOptionsPrivate.PlatformOptions[applicationCredentials]; + if (enableOverlay) + { + // Some caveats: + // - Currently the overlay is not implemented on non-Windows platforms + // - If you try to initialize EOS after the window has already been created, + // enabling the overlay will result in a crash + // - The overlay doesn't do anything if you do not log into an Epic account + platformInterfaceOptions.Flags = Epic.OnlineServices.Platform.PlatformFlags.None; + } + + platformInterface = Epic.OnlineServices.Platform.PlatformInterface.Create(ref platformInterfaceOptions); + + if (ConnectInterface != null) + { + LoginPrivate.Init(); + } + + if (platformInterface is null) { return Result.Failure(EosInterface.Core.InitError.PlatformInterfaceNotCreated); } + + PresencePrivate.Init(implementation); + + var setLogCallbackResult = Epic.OnlineServices.Logging.LoggingInterface.SetCallback(LogCallback); + if (setLogCallbackResult == Epic.OnlineServices.Result.Success) + { + Epic.OnlineServices.Logging.LoggingInterface.SetLogLevel( + Epic.OnlineServices.Logging.LogCategory.AllCategories, + Epic.OnlineServices.Logging.LogLevel.VeryVerbose); + } + + return Result.Success(default(Unit)); + } + + private static void LogCallback(ref Epic.OnlineServices.Logging.LogMessage msg) + { + DebugConsoleCore.Log($"[EOS {msg.Category} {msg.Level}] {msg.Message}"); + } + + public static Result CheckForLauncherAndRestart() + { + if (platformInterface is null) { return Result.Failure(EosInterface.Core.CheckForLauncherAndRestartError.EosNotInitialized); } + var result = platformInterface.CheckForLauncherAndRestart(); + if (result == Epic.OnlineServices.Result.Success) { return Result.Success(EosInterface.Core.WillRestartThroughLauncher.Yes); } + if (result == Epic.OnlineServices.Result.NoChange) { return Result.Success(EosInterface.Core.WillRestartThroughLauncher.No); } + return Result.Failure(result switch + { + Epic.OnlineServices.Result.UnexpectedError + => EosInterface.Core.CheckForLauncherAndRestartError.UnexpectedError, + _ + => result.FailAndLogUnhandledError(EosInterface.Core.CheckForLauncherAndRestartError.UnhandledErrorCondition) + }); + } + + private static EosInterface.Core.Status prevTickStatus = EosInterface.Core.Status.NotInitialized; + public static void Update() + { + platformInterface?.Tick(); + var currentStatus = CurrentStatus; + if (currentStatus == EosInterface.Core.Status.Online && prevTickStatus != currentStatus) + { + // We were offline, but now we are back online so let's update all sessions + OwnedSessionsPrivate.ForceUpdateAllOwnedSessions(); + } + prevTickStatus = currentStatus; + } + + public static void Quit() + { + PresencePrivate.Quit(); + + platformInterface?.Release(); + platformInterface = null; + Epic.OnlineServices.Platform.PlatformInterface.Shutdown(); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + + public override EosInterface.Core.Status CurrentStatus => CorePrivate.CurrentStatus; + + public override string NativeLibraryName => Epic.OnlineServices.Config.LibraryName; + + public override Result Init(EosInterface.ApplicationCredentials applicationCredentials, bool enableOverlay) + => CorePrivate.Init(this, applicationCredentials, enableOverlay); + + public override Result CheckForLauncherAndRestart() + => CorePrivate.CheckForLauncherAndRestart(); + + public override void Quit() + => CorePrivate.Quit(); + + public override void Update() + { + CorePrivate.Update(); + TaskScheduler.RunOnCurrentThread(); + } +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CustomTaskScheduler.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CustomTaskScheduler.cs new file mode 100644 index 000000000..18ae08313 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Core/CustomTaskScheduler.cs @@ -0,0 +1,67 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace EosInterfacePrivate; + +internal sealed partial class ImplementationPrivate : Barotrauma.EosInterface.Implementation +{ + /// + /// Custom TaskScheduler to force every EOS-related task to run on the main thread, because + /// the docs say the SDK is not thread-safe even though it's worked fine without this :/ + /// + /// See https://dev.epicgames.com/docs/epic-online-services/eos-get-started/eossdkc-sharp-getting-started#threading + /// + internal sealed class CustomTaskScheduler : TaskScheduler + { + private readonly ConcurrentQueue taskQueue = new ConcurrentQueue(); + + internal Task Schedule(Func> action) + { + return + Task.Factory.StartNew( + function: action, + cancellationToken: CancellationToken.None, + creationOptions: TaskCreationOptions.None, + scheduler: this).Unwrap(); + } + + internal Task Schedule(Func action) + { + return + Task.Factory.StartNew( + function: action, + cancellationToken: CancellationToken.None, + creationOptions: TaskCreationOptions.None, + scheduler: this).Unwrap(); + } + + internal void RunOnCurrentThread() + { + while (taskQueue.TryDequeue(out var task)) + { + TryExecuteTask(task); + } + } + + protected override IEnumerable GetScheduledTasks() + => Enumerable.Empty(); + + protected override void QueueTask(Task task) + { + taskQueue.Enqueue(task); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + // Never allow executing inline because that means it's not the main thread + return false; + } + } + + internal readonly CustomTaskScheduler TaskScheduler = new CustomTaskScheduler(); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/FriendsPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/FriendsPrivate.cs new file mode 100644 index 000000000..82040e4a1 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/FriendsPrivate.cs @@ -0,0 +1,249 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Barotrauma.Networking; +using Barotrauma; +using Barotrauma.Extensions; + +namespace EosInterfacePrivate; + +static class FriendsPrivate +{ + internal static async Task> GetUserInfoData( + EpicAccountId selfEaid, EpicAccountId friendEaid) + { + if (CorePrivate.EgsUserInfoInterface is not { } egsUserInfoInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); } + + var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(selfEaid.EosStringRepresentation); + var friendEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(friendEaid.EosStringRepresentation); + + var queryUserInfoOptions = new Epic.OnlineServices.UserInfo.QueryUserInfoOptions + { + LocalUserId = selfEaidInternal, + TargetUserId = friendEaidInternal + }; + var queryUserInfoWaiter = new CallbackWaiter(); + egsUserInfoInterface.QueryUserInfo(options: ref queryUserInfoOptions, clientData: null, completionDelegate: queryUserInfoWaiter.OnCompletion); + var queryUserInfoResult = await queryUserInfoWaiter.Task; + if (!queryUserInfoResult.TryUnwrap(out var queryUserInfo)) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.UserInfoQueryTimedOut); + } + if (queryUserInfo.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.UserInfoQueryFailed); + } + + var copyUserInfoOptions = new Epic.OnlineServices.UserInfo.CopyUserInfoOptions + { + LocalUserId = selfEaidInternal, + TargetUserId = friendEaidInternal + }; + var copyUserInfoResult = egsUserInfoInterface.CopyUserInfo(ref copyUserInfoOptions, out var friendInfoNullable); + if (copyUserInfoResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.CopyUserInfoFailed); + } + if (friendInfoNullable is not { } friendInfo) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.CopyUserInfoFailed); + } + + string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? ""; + if (string.IsNullOrEmpty(displayName)) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.DisplayNameIsEmpty); + } + + return Result.Success(friendInfo); + } + + internal static async Task> GetPresenceFromUserInfoData( + EpicAccountId selfEaid, EpicAccountId friendEaid, Epic.OnlineServices.UserInfo.UserInfoData friendInfo) + { + if (CorePrivate.EgsPresenceInterface is not { } egsPresenceInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); } + + var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(selfEaid.EosStringRepresentation); + var friendEaidInternal = friendInfo.UserId; + + string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? ""; + + var queryPresenceOptions = new Epic.OnlineServices.Presence.QueryPresenceOptions + { + LocalUserId = selfEaidInternal, + TargetUserId = friendEaidInternal + }; + var queryPresenceWaiter = new CallbackWaiter(); + egsPresenceInterface.QueryPresence(options: ref queryPresenceOptions, clientData: null, completionDelegate: queryPresenceWaiter.OnCompletion); + var queryPresenceResult = await queryPresenceWaiter.Task; + if (!queryPresenceResult.TryUnwrap(out var queryPresence)) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.EgsPresenceQueryTimedOut); + } + if (queryPresence.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.EgsPresenceQueryFailed); + } + + var copyPresenceOptions = new Epic.OnlineServices.Presence.CopyPresenceOptions + { + LocalUserId = selfEaidInternal, + TargetUserId = friendEaidInternal + }; + var copyPresenceResult = egsPresenceInterface.CopyPresence(ref copyPresenceOptions, out var friendPresenceNullable); + if (copyPresenceResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.CopyPresenceFailed); + } + if (friendPresenceNullable is not { } friendPresence) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.CopyPresenceFailed); + } + + string productId = friendPresence.ProductId ?? ""; + var friendStatus = friendPresence.Status switch + { + Epic.OnlineServices.Presence.Status.Offline + => FriendStatus.Offline, + _ + => productId == PlatformInterfaceOptionsPrivate.BasePlatformInterfaceOptions.ProductId + ? FriendStatus.PlayingBarotrauma + : !string.IsNullOrEmpty(productId) + ? FriendStatus.PlayingAnotherGame + : FriendStatus.NotPlaying + }; + + var records = friendPresence.Records ?? Array.Empty(); + + string getRecordValue(string key) + => records + .FirstOrNone(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) + .Select(r => r.Value) + .Fallback(""); + var connectCommand = getRecordValue("connectcommand"); + var serverName = getRecordValue("servername"); + + return Result.Success(new EosInterface.EgsFriend( + DisplayName: displayName, + EpicAccountId: friendEaid, + Status: friendStatus, + ConnectCommand: connectCommand, + ServerName: serverName)); + } + + public static async Task> GetSelfUserInfo(EpicAccountId epicAccount) + { + var getUserInfoDataResult = await GetUserInfoData(epicAccount, epicAccount); + if (getUserInfoDataResult.TryUnwrapFailure(out var error)) + { + return Result.Failure(error); + } + if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo)) + { + throw new UnreachableCodeException(); + } + + string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? ""; + + return Result.Success(new EosInterface.EgsFriend( + DisplayName: displayName, + EpicAccountId: epicAccount, + Status: FriendStatus.PlayingBarotrauma, + ConnectCommand: "", + ServerName: "")); + } + + public static async Task> GetFriend(EpicAccountId selfEaid, EpicAccountId friendEaid) + { + var getUserInfoDataResult = await GetUserInfoData(selfEaid, friendEaid); + if (getUserInfoDataResult.TryUnwrapFailure(out var error)) + { + return Result.Failure(error); + } + if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo)) + { + throw new UnreachableCodeException(); + } + + return await GetPresenceFromUserInfoData(selfEaid, friendEaid, friendInfo); + } + + public static async Task, EosInterface.Friends.GetFriendsError>> GetFriends(EpicAccountId epicAccount) + { + if (CorePrivate.EgsFriendsInterface is not { } egsFriendsInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); } + + var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(epicAccount.EosStringRepresentation); + + var queryFriendsOptions = new Epic.OnlineServices.Friends.QueryFriendsOptions + { + LocalUserId = selfEaidInternal + }; + var queryFriendsWaiter = new CallbackWaiter(); + egsFriendsInterface.QueryFriends(options: ref queryFriendsOptions, clientData: null, completionDelegate: queryFriendsWaiter.OnCompletion); + var queryFriendsInfoResult = await queryFriendsWaiter.Task; + if (!queryFriendsInfoResult.TryUnwrap(out var queryFriendsInfo)) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.EgsFriendsQueryTimedOut); + } + + if (queryFriendsInfo.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Friends.GetFriendsError.EgsFriendsQueryFailed); + } + + var getFriendsCountOptions = new Epic.OnlineServices.Friends.GetFriendsCountOptions + { + LocalUserId = selfEaidInternal + }; + var friendCount = egsFriendsInterface.GetFriendsCount(ref getFriendsCountOptions); + var friends = new List(); + + for (int i = 0; i < friendCount; i++) + { + var getFriendAtIndexOptions = new Epic.OnlineServices.Friends.GetFriendAtIndexOptions + { + LocalUserId = selfEaidInternal, + Index = i + }; + var friendId = egsFriendsInterface.GetFriendAtIndex(ref getFriendAtIndexOptions); + if (friendId == null) + { + continue; + } + + if (!EpicAccountId.Parse(friendId.ToString()).TryUnwrap(out var friendIdPublic)) + { + continue; + } + + var getUserInfoDataResult = await GetUserInfoData(epicAccount, friendIdPublic); + if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo)) + { + continue; + } + + var egsFriendPublicResult = await GetPresenceFromUserInfoData(epicAccount, friendIdPublic, friendInfo); + if (!egsFriendPublicResult.TryUnwrapSuccess(out var egsFriendPublic)) + { + continue; + } + + friends.Add(egsFriendPublic); + } + return Result.Success(friends.ToImmutableArray()); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> GetFriend(EpicAccountId selfEaid, EpicAccountId friendEaid) + => TaskScheduler.Schedule(() => FriendsPrivate.GetFriend(selfEaid, friendEaid)); + + public override Task, EosInterface.Friends.GetFriendsError>> GetFriends(EpicAccountId epicAccountId) + => TaskScheduler.Schedule(() => FriendsPrivate.GetFriends(epicAccountId)); + + public override Task> GetSelfUserInfo(EpicAccountId epicAccountId) + => TaskScheduler.Schedule(() => FriendsPrivate.GetSelfUserInfo(epicAccountId)); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/PresencePrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/PresencePrivate.cs new file mode 100644 index 000000000..c0dbde509 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Friends/PresencePrivate.cs @@ -0,0 +1,465 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma; +using Barotrauma.Extensions; +using EpicAccountId = Barotrauma.Networking.EpicAccountId; +using Result = Barotrauma.Result; + +namespace EosInterfacePrivate; + +static class PresencePrivate +{ + internal static readonly NamedEvent OnJoinGame = new NamedEvent(); + private static ulong joinGameAcceptedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid; + + internal static readonly NamedEvent OnInviteAccepted = new NamedEvent(); + private static ulong inviteAcceptedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid; + private static ulong inviteRejectedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid; + + internal static readonly NamedEvent OnInviteReceived = new NamedEvent(); + private static ulong inviteReceivedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid; + + public static void Init(ImplementationPrivate implementation) + { + var presenceInterface = CorePrivate.EgsPresenceInterface; + var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface; + if (presenceInterface is null + || customInvitesInterface is null) + { + return; + } + + var boilerplate0 = new Epic.OnlineServices.Presence.AddNotifyJoinGameAcceptedOptions(); + joinGameAcceptedNotificationId = presenceInterface.AddNotifyJoinGameAccepted(ref boilerplate0, null, OnJoinGameAcceptedEos); + + var boilerplate1 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteAcceptedOptions(); + inviteAcceptedNotificationId = customInvitesInterface.AddNotifyCustomInviteAccepted(ref boilerplate1, implementation, OnInviteAcceptedEos); + + var boilerplate2 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteRejectedOptions(); + inviteRejectedNotificationId = customInvitesInterface.AddNotifyCustomInviteRejected(ref boilerplate2, null, OnInviteRejectedEos); + + var boilerplate3 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteReceivedOptions(); + inviteReceivedNotificationId = customInvitesInterface.AddNotifyCustomInviteReceived(ref boilerplate3, implementation, OnInviteReceivedEos); + } + + public static void Quit() + { + OnJoinGame.Dispose(); + OnInviteAccepted.Dispose(); + + var presenceInterface = CorePrivate.EgsPresenceInterface; + var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface; + if (presenceInterface is null + || customInvitesInterface is null) + { + return; + } + + static void callRemover(Action remover, ref ulong id) + { + remover(id); + id = Epic.OnlineServices.Common.InvalidNotificationid; + } + + callRemover(presenceInterface.RemoveNotifyJoinGameAccepted, ref joinGameAcceptedNotificationId); + callRemover(customInvitesInterface.RemoveNotifyCustomInviteAccepted, ref inviteAcceptedNotificationId); + callRemover(customInvitesInterface.RemoveNotifyCustomInviteRejected, ref inviteRejectedNotificationId); + callRemover(customInvitesInterface.RemoveNotifyCustomInviteReceived, ref inviteReceivedNotificationId); + } + + private static void OnJoinGameAcceptedEos(ref Epic.OnlineServices.Presence.JoinGameAcceptedCallbackInfo data) + { + if (data.UiEventId != Epic.OnlineServices.UI.UIInterface.EventidInvalid) + { + // What is this for? I have no idea. + // Documentation says it's important tho: + // https://dev.epicgames.com/docs/epic-account-services/social-overlay-overview/sdk-integration#invite-lifecycle-and-caveats + var egsUiInterface = CorePrivate.EgsUiInterface; + if (egsUiInterface != null) + { + var ack = new Epic.OnlineServices.UI.AcknowledgeEventIdOptions + { + UiEventId = data.UiEventId, + Result = Epic.OnlineServices.Result.Success + }; + egsUiInterface.AcknowledgeEventId(ref ack); + } + } + + var selfEpicIdOption = EpicAccountId.Parse(data.LocalUserId.ToString()); + if (!selfEpicIdOption.TryUnwrap(out var selfEpicId)) { return; } + + var joinCommandStr = data.JoinInfo; + + OnJoinGame.Invoke(new EosInterface.Presence.JoinGameInfo(selfEpicId, joinCommandStr)); + } + + private static void OnInviteAcceptedEos(ref Epic.OnlineServices.CustomInvites.OnCustomInviteAcceptedCallbackInfo data) + { + if (data.LocalUserId is null) { return; } + if (data.ClientData is not ImplementationPrivate implementation) { return; } + + RemoveInvite( + recipientPuid: new EosInterface.ProductUserId(data.LocalUserId.ToString()), + senderPuid: new EosInterface.ProductUserId(data.TargetUserId.ToString())); + + var joinCommandStr = data.Payload; + + var selfPuid = new EosInterface.ProductUserId(data.LocalUserId.ToString()); + + async Task> prepareCallbackInfo() + { + var selfExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, selfPuid); + + await Task.WhenAll(selfExternalAccountIdsTask, selfExternalAccountIdsTask); + + var selfExternalAccountIdsResult = await selfExternalAccountIdsTask; + + if (!selfExternalAccountIdsResult.TryUnwrapSuccess(out var selfExternalAccountIds) + || !selfExternalAccountIds.OfType().FirstOrNone().TryUnwrap(out var selfEpicAccountId)) + { + return Option.None; + } + + return Option.Some(new EosInterface.Presence.AcceptInviteInfo( + selfEpicAccountId, + joinCommandStr)); + } + + TaskPool.Add( + $"AcceptedInviteFor{selfPuid.Value}", + implementation.TaskScheduler.Schedule(prepareCallbackInfo), + t => + { + if (!t.TryGetResult(out Option infoOption)) { return; } + if (!infoOption.TryUnwrap(out var info)) { return; } + + OnInviteAccepted.Invoke(info); + }); + } + + private static void OnInviteRejectedEos(ref Epic.OnlineServices.CustomInvites.CustomInviteRejectedCallbackInfo data) + { + if (data.LocalUserId is null) { return; } + + RemoveInvite( + recipientPuid: new EosInterface.ProductUserId(data.LocalUserId.ToString()), + senderPuid: new EosInterface.ProductUserId(data.TargetUserId.ToString())); + } + + private readonly record struct InviteId( + EpicAccountId RecipientEpicId, + EpicAccountId SenderEpicId, + EosInterface.ProductUserId RecipientPuid, + EosInterface.ProductUserId SenderPuid, + string IdValue); + + private static readonly List ReceivedInviteIds = new List(); + + private static void RemoveInvite(EpicAccountId recipientEpicId, EpicAccountId senderEpicId) + { + RemoveInvites(ReceivedInviteIds.Where(id => id.RecipientEpicId == recipientEpicId && id.SenderEpicId == senderEpicId).ToImmutableArray()); + } + + private static void RemoveInvite(EosInterface.ProductUserId recipientPuid, EosInterface.ProductUserId senderPuid) + { + RemoveInvites(ReceivedInviteIds.Where(id => id.RecipientPuid == recipientPuid && id.SenderPuid == senderPuid).ToImmutableArray()); + } + + private static void RemoveInvites(ImmutableArray invites) + { + var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface; + if (customInvitesInterface == null) { return; } + + foreach (var invite in invites) + { + ReceivedInviteIds.Remove(invite); + var targetUserId = Epic.OnlineServices.ProductUserId.FromString(invite.SenderPuid.Value); + var localUserId = Epic.OnlineServices.ProductUserId.FromString(invite.RecipientPuid.Value); + var finalizeInviteOptions = new Epic.OnlineServices.CustomInvites.FinalizeInviteOptions + { + TargetUserId = targetUserId, + LocalUserId = localUserId, + CustomInviteId = invite.IdValue, + ProcessingResult = Epic.OnlineServices.Result.Success + }; + customInvitesInterface.FinalizeInvite(ref finalizeInviteOptions); + } + } + + private static void OnInviteReceivedEos(ref Epic.OnlineServices.CustomInvites.OnCustomInviteReceivedCallbackInfo data) + { + if (data.ClientData is not ImplementationPrivate implementation) { return; } + var joinCommandStr = data.Payload; + + var selfPuid = new EosInterface.ProductUserId(data.LocalUserId.ToString()); + var senderPuid = new EosInterface.ProductUserId(data.TargetUserId.ToString()); + var inviteIdValue = data.CustomInviteId; + + // We can only have one invite for the same recipient-sender pair + RemoveInvite( + recipientPuid: selfPuid, + senderPuid: senderPuid); + + async Task> prepareCallbackInfo() + { + var selfExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, selfPuid); + var senderExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, senderPuid); + + await Task.WhenAll(selfExternalAccountIdsTask, selfExternalAccountIdsTask); + + var selfExternalAccountIdsResult = await selfExternalAccountIdsTask; + var senderExternalAccountIdsResult = await senderExternalAccountIdsTask; + + if (!selfExternalAccountIdsResult.TryUnwrapSuccess(out var selfExternalAccountIds) + || !selfExternalAccountIds.OfType().FirstOrNone().TryUnwrap(out var selfEpicAccountId)) + { + return Option.None; + } + + if (!senderExternalAccountIdsResult.TryUnwrapSuccess(out var senderExternalAccountIds) + || !senderExternalAccountIds.OfType().FirstOrNone().TryUnwrap(out var senderEpicAccountId)) + { + return Option.None; + } + + return Option.Some(new EosInterface.Presence.ReceiveInviteInfo( + selfEpicAccountId, + senderEpicAccountId, + joinCommandStr)); + } + + TaskPool.Add( + $"ReceivedInviteFrom{senderPuid.Value}", + implementation.TaskScheduler.Schedule(prepareCallbackInfo), + t => + { + if (!t.TryGetResult(out Option infoOption)) { return; } + + if (!infoOption.TryUnwrap(out var info)) { return; } + + ReceivedInviteIds.Add(new InviteId( + RecipientEpicId: info.RecipientId, + SenderEpicId: info.SenderId, + RecipientPuid: selfPuid, + SenderPuid: senderPuid, + IdValue: inviteIdValue)); + + OnInviteReceived.Invoke(info); + }); + } + + public static async Task> SetJoinCommand( + EpicAccountId epicAccountId, + string desc, + string serverName, + string joinCommand) + { + if (string.IsNullOrWhiteSpace(joinCommand)) + { + desc = ""; + } + + if (desc.Length > Epic.OnlineServices.Presence.PresenceInterface.RichTextMaxValueLength) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.DescTooLong); + } + if (joinCommand.Length > Epic.OnlineServices.Presence.PresenceModification.PresencemodificationJoininfoMaxLength) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.JoinCommandTooLong); + } + + if (serverName.Length > Epic.OnlineServices.Presence.PresenceInterface.DataMaxValueLength) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.ServerNameTooLong); + } + if (joinCommand.Length > Epic.OnlineServices.Presence.PresenceInterface.DataMaxValueLength) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.JoinCommandTooLong); + } + + using var janitor = Janitor.Start(); + + var presenceInterface = CorePrivate.EgsPresenceInterface; + var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface; + if (presenceInterface is null + || customInvitesInterface is null) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.EosNotInitialized); + } + + var epicAccountIdInternal = Epic.OnlineServices.EpicAccountId.FromString(epicAccountId.EosStringRepresentation); + + var puidResult = await IdQueriesPrivate.GetPuidForExternalId(epicAccountId); + if (!puidResult.TryUnwrapSuccess(out var puid)) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToGetPuid); + } + + var puidInternal = Epic.OnlineServices.ProductUserId.FromString(puid.Value); + + var setCustomInviteOptions = new Epic.OnlineServices.CustomInvites.SetCustomInviteOptions + { + LocalUserId = puidInternal, + Payload = joinCommand + }; + var setCustomInviteResult = customInvitesInterface.SetCustomInvite(ref setCustomInviteOptions); + if (setCustomInviteResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetCustomInvite); + } + + var createPresenceModificationOptions = new Epic.OnlineServices.Presence.CreatePresenceModificationOptions + { + LocalUserId = epicAccountIdInternal + }; + var createPresenceModificationResult = presenceInterface.CreatePresenceModification(ref createPresenceModificationOptions, out var presenceModification); + janitor.AddAction(presenceModification.Release); + if (createPresenceModificationResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToCreatePresenceModification); + } + + var setRichTextOptions = new Epic.OnlineServices.Presence.PresenceModificationSetRawRichTextOptions + { + RichText = desc + }; + var setRichTextResult = presenceModification.SetRawRichText(ref setRichTextOptions); + if (setRichTextResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetRichText); + } + + var setDataOptions = new Epic.OnlineServices.Presence.PresenceModificationSetDataOptions + { + Records = new[] + { + new Epic.OnlineServices.Presence.DataRecord + { + Key = "servername", + Value = serverName + }, + new Epic.OnlineServices.Presence.DataRecord + { + Key = "connectcommand", + Value = joinCommand + } + } + }; + var setDataResult = presenceModification.SetData(ref setDataOptions); + if (setDataResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetRecords); + } + + // This is necessary to make the SDK not choke if given an empty, but not null, joinCommand + string? joinCommandNullable = string.IsNullOrWhiteSpace(joinCommand) ? null : joinCommand; + + var setJoinInfoOptions = new Epic.OnlineServices.Presence.PresenceModificationSetJoinInfoOptions + { + JoinInfo = joinCommandNullable + }; + var setJoinInfoResult = presenceModification.SetJoinInfo(ref setJoinInfoOptions); + if (setJoinInfoResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetJoinInfo); + } + + var setPresenceOptions = new Epic.OnlineServices.Presence.SetPresenceOptions + { + LocalUserId = epicAccountIdInternal, + PresenceModificationHandle = presenceModification + }; + var setPresenceWaiter = new CallbackWaiter(); + presenceInterface.SetPresence(options: ref setPresenceOptions, clientData: null, completionDelegate: setPresenceWaiter.OnCompletion); + var setPresenceResultOption = await setPresenceWaiter.Task; + if (!setPresenceResultOption.TryUnwrap(out var setPresenceResult)) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.SetPresenceTimedOut); + } + + if (setPresenceResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetPresence); + } + + return Result.Success(Unit.Value); + } + + public static async Task> SendInvite(EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId) + { + var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface; + if (customInvitesInterface is null) + { + return Result.Failure(EosInterface.Presence.SendInviteError.EosNotInitialized); + } + + var selfPuidResult = await IdQueriesPrivate.GetPuidForExternalId(selfEpicAccountId); + if (!selfPuidResult.TryUnwrapSuccess(out var selfPuid)) + { + return Result.Failure(EosInterface.Presence.SendInviteError.FailedToGetSelfPuid); + } + + var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value); + + var remotePuidResult = await IdQueriesPrivate.GetPuidForExternalId(remoteEpicAccountId); + if (!remotePuidResult.TryUnwrapSuccess(out var remotePuid)) + { + return Result.Failure(EosInterface.Presence.SendInviteError.FailedToGetRemotePuid); + } + + var remotePuidInternal = Epic.OnlineServices.ProductUserId.FromString(remotePuid.Value); + + var sendCustomInviteOptions = new Epic.OnlineServices.CustomInvites.SendCustomInviteOptions + { + LocalUserId = selfPuidInternal, + TargetUserIds = new[] + { + remotePuidInternal + } + }; + var callbackWaiter = new CallbackWaiter(); + customInvitesInterface.SendCustomInvite(options: ref sendCustomInviteOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion); + var callbackResultOption = await callbackWaiter.Task; + if (!callbackResultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.Presence.SendInviteError.TimedOut); + } + + if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Presence.SendInviteError.InternalError); + } + + return Result.Success(Unit.Value); + } + + public static void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId) + { + RemoveInvite( + recipientEpicId: selfEpicAccountId, + senderEpicId: senderEpicAccountId); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override NamedEvent OnJoinGame => PresencePrivate.OnJoinGame; + public override NamedEvent OnInviteAccepted => PresencePrivate.OnInviteAccepted; + public override NamedEvent OnInviteReceived => PresencePrivate.OnInviteReceived; + + public override Task> SetJoinCommand( + EpicAccountId epicAccountId, string desc, string serverName, string joinCommand) + => TaskScheduler.Schedule(() => PresencePrivate.SetJoinCommand(epicAccountId, desc, serverName, joinCommand)); + + public override Task> SendInvite( + EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId) + => TaskScheduler.Schedule(() => PresencePrivate.SendInvite(selfEpicAccountId, remoteEpicAccountId)); + + public override void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId) + => PresencePrivate.DeclineInvite(selfEpicAccountId, senderEpicAccountId); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EgsIdTokenPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EgsIdTokenPrivate.cs new file mode 100644 index 000000000..ebf780b44 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EgsIdTokenPrivate.cs @@ -0,0 +1,119 @@ +#nullable enable +using System.Text.Json; +using System.Threading.Tasks; +using Barotrauma.Networking; +using Barotrauma; + +namespace EosInterfacePrivate; + +public sealed class EgsIdTokenPrivate : EosInterface.EgsIdToken +{ + public override EpicAccountId AccountId { get; } + + internal static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions + { + IncludeFields = true + }; + + internal readonly record struct TokenStruct( + string AccountId, + string JsonWebToken); + + internal readonly Epic.OnlineServices.Auth.IdToken InternalToken; + internal EgsIdTokenPrivate(EpicAccountId accountId, Epic.OnlineServices.Auth.IdToken internalToken) + { + AccountId = accountId; + InternalToken = internalToken; + } + + public new static Option Parse(string str) + { + try + { + if (JsonSerializer.Deserialize( + str, + returnType: typeof(TokenStruct), + options: JsonSerializerOptions) + is not TokenStruct tokenStruct) + { + return Option.None; + } + + if (!EpicAccountId.Parse(tokenStruct.AccountId).TryUnwrap(out var accountId)) { return Option.None; } + + var internalToken = new Epic.OnlineServices.Auth.IdToken + { + AccountId = Epic.OnlineServices.EpicAccountId.FromString(tokenStruct.AccountId), + JsonWebToken = tokenStruct.JsonWebToken + }; + + return Option.Some(new EgsIdTokenPrivate(accountId, internalToken)); + } + catch + { + return Option.None; + } + } + + public override string ToString() + { + var tokenStruct = new TokenStruct( + AccountId: InternalToken.AccountId.ToString(), + JsonWebToken: InternalToken.JsonWebToken); + return JsonSerializer.Serialize(tokenStruct, options: JsonSerializerOptions); + } + + public static Result GetEgsIdTokenForEpicAccountId(EpicAccountId accountId) + { + var (success, failure) = Result.GetFactoryMethods(); + + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return failure(EosInterface.GetEgsSelfIdTokenError.EosNotInitialized); } + + var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions + { + AccountId = Epic.OnlineServices.EpicAccountId.FromString(accountId.EosStringRepresentation) + }; + var copyIdTokenResult = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var idTokenNullable); + + if (copyIdTokenResult is Epic.OnlineServices.Result.NotFound) { return failure(EosInterface.GetEgsSelfIdTokenError.InvalidToken); } + if (copyIdTokenResult != Epic.OnlineServices.Result.Success) { return failure(EosInterface.GetEgsSelfIdTokenError.UnhandledErrorCondition); } + if (idTokenNullable is not { } idToken) { return failure(EosInterface.GetEgsSelfIdTokenError.UnhandledErrorCondition); } + + return success(new EgsIdTokenPrivate(accountId, idToken)); + } + + public override async Task Verify(AccountId accountId) + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return EosInterface.VerifyEgsIdTokenResult.Failed; } + + var verifyIdTokenOptions = new Epic.OnlineServices.Auth.VerifyIdTokenOptions + { + IdToken = InternalToken + }; + var verifyIdTokenWaiter = new CallbackWaiter(); + egsAuthInterface.VerifyIdToken(options: ref verifyIdTokenOptions, clientData: null, completionDelegate: verifyIdTokenWaiter.OnCompletion); + var result = await verifyIdTokenWaiter.Task; + if (!result.TryUnwrap(out var callbackInfo)) { return EosInterface.VerifyEgsIdTokenResult.Failed; } + + if (callbackInfo.ResultCode != Epic.OnlineServices.Result.Success + || callbackInfo.ProductId != CorePrivate.PlatformInterfaceOptions.ProductId) + { + return EosInterface.VerifyEgsIdTokenResult.Failed; + } + + var resultAccountId = IdQueriesPrivate.EosStringToAccountId(callbackInfo.ExternalAccountId, callbackInfo.ExternalAccountIdType); + + return resultAccountId.TryUnwrap(out var resultId) && resultId == accountId + ? EosInterface.VerifyEgsIdTokenResult.Verified + : EosInterface.VerifyEgsIdTokenResult.Failed; + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Option ParseEgsIdToken(string str) + => EgsIdTokenPrivate.Parse(str).Select(t => (EosInterface.EgsIdToken)t); + + public override Result GetEgsIdTokenForEpicAccountId(EpicAccountId accountId) + => EgsIdTokenPrivate.GetEgsIdTokenForEpicAccountId(accountId); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EosIdTokenPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EosIdTokenPrivate.cs new file mode 100644 index 000000000..6069eb30c --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/EosIdTokenPrivate.cs @@ -0,0 +1,73 @@ +#nullable enable +using System.Threading.Tasks; +using Barotrauma.Networking; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class EosIdTokenPrivate +{ + public static Result GetEosIdTokenForProductUserId(EosInterface.ProductUserId puid) + { + var (success, failure) = Result.GetFactoryMethods(); + + if (CorePrivate.ConnectInterface is not { } connectInterface) { return failure(EosInterface.GetEosSelfIdTokenError.EosNotInitialized); } + + var copyIdTokenOptions = new Epic.OnlineServices.Connect.CopyIdTokenOptions + { + LocalUserId = Epic.OnlineServices.ProductUserId.FromString(puid.Value) + }; + var copyIdTokenResult = connectInterface.CopyIdToken(ref copyIdTokenOptions, out var idTokenNullable); + + if (copyIdTokenResult is Epic.OnlineServices.Result.NotFound) { return failure(EosInterface.GetEosSelfIdTokenError.InvalidToken); } + if (copyIdTokenResult != Epic.OnlineServices.Result.Success) { return failure(EosInterface.GetEosSelfIdTokenError.UnhandledErrorCondition); } + if (idTokenNullable is not { } idToken) { return failure(EosInterface.GetEosSelfIdTokenError.UnhandledErrorCondition); } + + if (!JsonWebToken.Parse(idToken.JsonWebToken).TryUnwrap(out var jsonWebToken)) { return failure(EosInterface.GetEosSelfIdTokenError.CouldNotParseJwt); } + + return success(new EosInterface.EosIdToken(new EosInterface.ProductUserId(idToken.ProductUserId.ToString()), jsonWebToken)); + } + + public static async Task> Verify(EosInterface.EosIdToken token) + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.VerifyEosIdTokenError.EosNotInitialized); } + + var verifyIdTokenOptions = new Epic.OnlineServices.Connect.VerifyIdTokenOptions + { + IdToken = new Epic.OnlineServices.Connect.IdToken + { + ProductUserId = Epic.OnlineServices.ProductUserId.FromString(token.ProductUserId.Value), + JsonWebToken = token.JsonWebToken.ToString() + } + }; + var verifyIdTokenWaiter = new CallbackWaiter(); + connectInterface.VerifyIdToken(options: ref verifyIdTokenOptions, clientData: null, completionDelegate: verifyIdTokenWaiter.OnCompletion); + var result = await verifyIdTokenWaiter.Task; + if (!result.TryUnwrap(out var callbackInfo)) { return Result.Failure(EosInterface.VerifyEosIdTokenError.TimedOut); } + + if (callbackInfo.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.VerifyEosIdTokenError.UnhandledErrorCondition); + } + + if (callbackInfo.ProductId != CorePrivate.PlatformInterfaceOptions.ProductId) + { + return Result.Failure(EosInterface.VerifyEosIdTokenError.ProductIdDidNotMatch); + } + + var resultAccountId = IdQueriesPrivate.EosStringToAccountId(callbackInfo.AccountId, callbackInfo.AccountIdType); + + return resultAccountId.TryUnwrap(out var resultId) + ? Result.Success(resultId) + : Result.Failure(EosInterface.VerifyEosIdTokenError.CouldNotParseExternalAccountId); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> VerifyEosIdToken(EosInterface.EosIdToken token) + => TaskScheduler.Schedule(() => EosIdTokenPrivate.Verify(token)); + + public override Result GetEosIdTokenForProductUserId(EosInterface.ProductUserId puid) + => EosIdTokenPrivate.GetEosIdTokenForProductUserId(puid); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/IdQueriesPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/IdQueriesPrivate.cs new file mode 100644 index 000000000..b561ce341 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/IdQueriesPrivate.cs @@ -0,0 +1,222 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma.Extensions; +using Barotrauma.Networking; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class IdQueriesPrivate +{ + public static ImmutableArray GetLoggedInPuids() + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return ImmutableArray.Empty; } + + int count = connectInterface.GetLoggedInUsersCount(); + var ids = new List(); + foreach (int i in Enumerable.Range(0, count)) + { + if (connectInterface.GetLoggedInUserByIndex(i) is not { } userId) { return ImmutableArray.Empty; } + var newPuid = new EosInterface.ProductUserId(userId.ToString()); + if (!LoginPrivate.PuidToPrimaryExternalId.ContainsKey(newPuid)) { continue; } + ids.Add(newPuid); + } + + return ids.ToImmutableArray(); + } + + public static ImmutableArray GetLoggedInEpicIds() + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return ImmutableArray.Empty; } + + int count = egsAuthInterface.GetLoggedInAccountsCount(); + var ids = new List(); + foreach (int i in Enumerable.Range(0, count)) + { + if (egsAuthInterface.GetLoggedInAccountByIndex(i) is not { } userId) { return ImmutableArray.Empty; } + var newEpicIdOption = EpicAccountId.Parse(userId.ToString()); + if (!newEpicIdOption.TryUnwrap(out var newEpicId)) { return ImmutableArray.Empty; } + ids.Add(newEpicId); + } + + return ids.ToImmutableArray(); + } + + public static Task, EosInterface.IdQueries.GetSelfExternalIdError>> + GetSelfExternalAccountIds( + EosInterface.ProductUserId productUserId) + => GetExternalAccountIds(productUserId, productUserId); + + internal static async Task, EosInterface.IdQueries.GetSelfExternalIdError>> + GetExternalAccountIds( + EosInterface.ProductUserId selfPuid, + EosInterface.ProductUserId puidToGetIdsFor) + { + // If logged only into an Epic account, you cannot fetch SteamIDs. + // See Epic.OnlineServices.Connect.ExternalAccountInfo.AccountId + + var (success, failure) = Result, EosInterface.IdQueries.GetSelfExternalIdError>.GetFactoryMethods(); + + if (CorePrivate.ConnectInterface is not { } connectInterface) + { + return failure(EosInterface.IdQueries.GetSelfExternalIdError.EosNotInitialized); + } + if (!LoginPrivate.PuidToPrimaryExternalId.ContainsKey(selfPuid)) + { + return failure(EosInterface.IdQueries.GetSelfExternalIdError.Inaccessible); + } + + var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value); + var otherPuidInternal = Epic.OnlineServices.ProductUserId.FromString(puidToGetIdsFor.Value); + + var queryProductUserIdMappingsOptions = new Epic.OnlineServices.Connect.QueryProductUserIdMappingsOptions + { + LocalUserId = selfPuidInternal, + ProductUserIds = new[] { otherPuidInternal } + }; + + var queryWaiter = new CallbackWaiter(); + connectInterface.QueryProductUserIdMappings(options: ref queryProductUserIdMappingsOptions, clientData: null, completionDelegate: queryWaiter.OnCompletion); + var queryResultOption = await queryWaiter.Task; + if (!queryResultOption.TryUnwrap(out var queryResult)) + { + return failure(EosInterface.IdQueries.GetSelfExternalIdError.Timeout); + } + + if (queryResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return failure(queryResult.ResultCode switch + { + Epic.OnlineServices.Result.NotFound => EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser, + Epic.OnlineServices.Result.InvalidUser => EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser, + var unhandled => unhandled.FailAndLogUnhandledError(EosInterface.IdQueries.GetSelfExternalIdError.UnhandledErrorCondition) + }); + } + + var getProductUserExternalAccountCountOptions = new Epic.OnlineServices.Connect.GetProductUserExternalAccountCountOptions + { + TargetUserId = otherPuidInternal + }; + + uint count = connectInterface.GetProductUserExternalAccountCount(ref getProductUserExternalAccountCountOptions); + var accountIds = new AccountId[count]; + + foreach (int i in Enumerable.Range(0, (int)count)) + { + var copyProductUserExternalAccountByIndexOptions = new Epic.OnlineServices.Connect.CopyProductUserExternalAccountByIndexOptions + { + TargetUserId = otherPuidInternal, + ExternalAccountInfoIndex = (uint)i + }; + + connectInterface.CopyProductUserExternalAccountByIndex( + ref copyProductUserExternalAccountByIndexOptions, + out var externalAccountInfoNullable); + if (!externalAccountInfoNullable.TryGetValue(out var externalAccountInfo)) + { + return failure(EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser); + } + + var accountIdOption = + EosStringToAccountId(externalAccountInfo.AccountId, externalAccountInfo.AccountIdType); + if (!accountIdOption.TryUnwrap(out var accountId)) + { + return failure(EosInterface.IdQueries.GetSelfExternalIdError.ParseError); + } + + accountIds[i] = accountId; + } + + return success(accountIds.ToImmutableArray()); + } + + internal static async Task> GetPuidForExternalId(AccountId externalId) + { + var connectInterface = CorePrivate.ConnectInterface; + if (connectInterface is null) + { + return Result.Failure(Epic.OnlineServices.Result.NotConfigured); + } + + var externalAccountType = externalId is EpicAccountId + ? Epic.OnlineServices.ExternalAccountType.Epic + : Epic.OnlineServices.ExternalAccountType.Steam; + string externalAccountEosRepresentation = externalId.EosStringRepresentation; + + Result lastError + = Result.Failure(Epic.OnlineServices.Result.UnexpectedError); + foreach (var selfPuid in GetLoggedInPuids() + .OrderByDescending(id => LoginPrivate.PuidToPrimaryExternalId[id].GetType() == externalId.GetType())) + { + var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value); + + // See https://dev.epicgames.com/docs/en-US/api-ref/functions/eos-connect-query-external-account-mappings + // to learn why we need to call this function before we call GetExternalAccountMapping + var queryExternalAccountMappingsOptions = new Epic.OnlineServices.Connect.QueryExternalAccountMappingsOptions + { + LocalUserId = selfPuidInternal, + AccountIdType = externalAccountType, + ExternalAccountIds = new Epic.OnlineServices.Utf8String[] + { + externalAccountEosRepresentation + } + }; + + var queryExternalAccountMappingsWaiter = new CallbackWaiter(); + connectInterface.QueryExternalAccountMappings(options: ref queryExternalAccountMappingsOptions, clientData: null, completionDelegate: queryExternalAccountMappingsWaiter.OnCompletion); + var resultOption = await queryExternalAccountMappingsWaiter.Task; + if (!resultOption.TryUnwrap(out var result)) + { + lastError = Result.Failure(Epic.OnlineServices.Result.TimedOut); + continue; + } + + if (result.ResultCode != Epic.OnlineServices.Result.Success) + { + lastError = Result.Failure(result.ResultCode); + continue; + } + + var getExternalAccountMappingsOptions = new Epic.OnlineServices.Connect.GetExternalAccountMappingsOptions + { + LocalUserId = selfPuidInternal, + AccountIdType = externalAccountType, + TargetExternalUserId = externalAccountEosRepresentation + }; + var otherPuid = connectInterface.GetExternalAccountMapping(ref getExternalAccountMappingsOptions); + if (otherPuid is null) + { + lastError = Result.Failure(Epic.OnlineServices.Result.NotFound); + continue; + } + return Result.Success(new EosInterface.ProductUserId(otherPuid.ToString())); + } + + return lastError; + } + + public static Option EosStringToAccountId( + string stringRepresentation, + Epic.OnlineServices.ExternalAccountType accountType) + => accountType switch + { + Epic.OnlineServices.ExternalAccountType.Steam => SteamId.Parse(stringRepresentation).Select(id => (AccountId)id), + Epic.OnlineServices.ExternalAccountType.Epic => EpicAccountId.Parse(stringRepresentation).Select(id => (AccountId)id), + _ => Option.None + }; +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override ImmutableArray GetLoggedInPuids() + => IdQueriesPrivate.GetLoggedInPuids(); + + public override ImmutableArray GetLoggedInEpicIds() + => IdQueriesPrivate.GetLoggedInEpicIds(); + + public override Task, EosInterface.IdQueries.GetSelfExternalIdError>> GetSelfExternalAccountIds(EosInterface.ProductUserId puid) + => TaskScheduler.Schedule(() => IdQueriesPrivate.GetSelfExternalAccountIds(puid)); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/LoginPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/LoginPrivate.cs new file mode 100644 index 000000000..689ff998a --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/LoginPrivate.cs @@ -0,0 +1,708 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Barotrauma.Debugging; +using Barotrauma.Networking; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class LoginPrivate +{ + private const string EosLoginSteamIdentity = "BarotraumaEosLogin"; + private static Option steamworksAuthTicket; + + private static Option eosConnectExpirationNotifyId, eosConnectStatusChangedNotifyId; + private static Option egsAuthExpirationNotifyId; + + internal static void Init() + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return; } + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return; } + + ClearNotificationId(ref egsAuthExpirationNotifyId, egsAuthInterface.RemoveNotifyLoginStatusChanged); + var authExpirationOptions = new Epic.OnlineServices.Auth.AddNotifyLoginStatusChangedOptions(); + ulong authExpirationNotifyId = egsAuthInterface.AddNotifyLoginStatusChanged(ref authExpirationOptions, null, OnEgsAuthStatusChanged); + StoreNotificationId(out egsAuthExpirationNotifyId, authExpirationNotifyId); + + ClearNotificationId(ref eosConnectExpirationNotifyId, connectInterface.RemoveNotifyAuthExpiration); + var connectExpirationOptions = new Epic.OnlineServices.Connect.AddNotifyAuthExpirationOptions(); + ulong connectExpirationNotifyId = connectInterface.AddNotifyAuthExpiration(ref connectExpirationOptions, null, OnConnectExpiration); + StoreNotificationId(out eosConnectExpirationNotifyId, connectExpirationNotifyId); + + ClearNotificationId(ref eosConnectStatusChangedNotifyId, connectInterface.RemoveNotifyLoginStatusChanged); + var addNotifyConnectStatusChangedOptions = new Epic.OnlineServices.Connect.AddNotifyLoginStatusChangedOptions(); + var connectChangedNotifyId = connectInterface.AddNotifyLoginStatusChanged(ref addNotifyConnectStatusChangedOptions, null, OnConnectStatusChanged); + StoreNotificationId(out eosConnectStatusChangedNotifyId, connectChangedNotifyId); + + static void ClearNotificationId(ref Option field, Action clearAction) + { + if (field.TryUnwrap(out var notificationId)) + { + clearAction(notificationId); + } + field = Option.None; + } + + static void StoreNotificationId(out Option field, ulong value) + { + bool isValid = value is not Epic.OnlineServices.Common.InvalidNotificationid; + field = isValid + ? Option.Some(value) + : Option.None; + } + } + + internal static readonly ConcurrentDictionary PuidToPrimaryExternalId = new(); + + private readonly record struct LoginParams( + Epic.OnlineServices.Connect.Credentials Credentials, + AccountId ExternalAccountId); + + private static async Task> GenCredentialsSteam() + { + if (!Steamworks.SteamClient.IsValid || !Steamworks.SteamClient.IsLoggedOn) { return Result.Failure(EosInterface.Login.LoginError.SteamNotLoggedIn); } + if (steamworksAuthTicket.TryUnwrap(out var oldTicket)) { oldTicket.Cancel(); } + var newTicketNullable = await Steamworks.SteamUser.GetAuthTicketForWebApi(EosLoginSteamIdentity); + if (newTicketNullable is not { Data: not null } ticket) + { + return Result.Failure(EosInterface.Login.LoginError.FailedToGetSteamSessionTicket); + } + return Result.Success( + new LoginParams( + Credentials: new Epic.OnlineServices.Connect.Credentials + { + Token = ToolBoxCore.ByteArrayToHexString(ticket.Data), + Type = Epic.OnlineServices.ExternalCredentialType.SteamSessionTicket + }, + ExternalAccountId: new SteamId(Steamworks.SteamClient.SteamId))); + } + + private static async Task, EosInterface.Login.LoginError>> GenCredentialsEpic( + Epic.OnlineServices.Auth.LoginCredentialType credentialsType, + string? credentialsId, + string? credentialsToken, + Epic.OnlineServices.ExternalCredentialType credentialsExternalType, + EosInterface.Login.LoginEpicFlags flags) + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LoginError.EosNotInitialized); } + + if (credentialsType is not ( + Epic.OnlineServices.Auth.LoginCredentialType.ExternalAuth + or Epic.OnlineServices.Auth.LoginCredentialType.Developer + or Epic.OnlineServices.Auth.LoginCredentialType.ExchangeCode)) + { + return Result.Failure(EosInterface.Login.LoginError.InvalidUser); + } + + var authLoginOptions = new Epic.OnlineServices.Auth.LoginOptions + { + Credentials = new Epic.OnlineServices.Auth.Credentials + { + Id = credentialsId, + Token = credentialsToken, + Type = credentialsType, + SystemAuthCredentialsOptions = default, + ExternalType = credentialsExternalType + }, + ScopeFlags = + Epic.OnlineServices.Auth.AuthScopeFlags.BasicProfile + | Epic.OnlineServices.Auth.AuthScopeFlags.Presence + | Epic.OnlineServices.Auth.AuthScopeFlags.FriendsList, + LoginFlags = flags.HasFlag(EosInterface.Login.LoginEpicFlags.FailWithoutOpeningBrowser) + ? Epic.OnlineServices.Auth.LoginFlags.NoUserInterface + : Epic.OnlineServices.Auth.LoginFlags.None + }; + + var authLoginWaiter = new CallbackWaiter(); + egsAuthInterface.Login(options: ref authLoginOptions, clientData: null, completionDelegate: authLoginWaiter.OnCompletion); + + // This can time out if authLoginOptions.ScopeFlags is set incorrectly, + // because the docs lied and this callback isn't guaranteed to be called + var authLoginCallbackInfoOption = await authLoginWaiter.Task; + + if (!authLoginCallbackInfoOption.TryUnwrap(out var authLoginCallbackInfo)) + { + return Result.Failure(EosInterface.Login.LoginError.EgsLoginTimeout); + } + if (authLoginCallbackInfo is { ResultCode: Epic.OnlineServices.Result.InvalidUser, ContinuanceToken: { } continuanceToken }) + { + return Result.Success((Either)continuanceToken); + } + + if (authLoginCallbackInfo.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(authLoginCallbackInfo.ResultCode switch { + Epic.OnlineServices.Result.NotFound + => EosInterface.Login.LoginError.EgsAccountNotFound, + Epic.OnlineServices.Result.AuthExchangeCodeNotFound + => EosInterface.Login.LoginError.AuthExchangeCodeNotFound, + Epic.OnlineServices.Result.AuthUserInterfaceRequired + => EosInterface.Login.LoginError.AuthRequiresOpeningBrowser, + Epic.OnlineServices.Result.AccessDenied + => EosInterface.Login.LoginError.EgsAccessDenied, + _ + => EosInterface.Login.LoginError.UnhandledFailureCondition + }); + } + if (!EpicAccountId.Parse(authLoginCallbackInfo.LocalUserId.ToString()).TryUnwrap(out var externalAccountId)) + { + return Result.Failure(EosInterface.Login.LoginError.FailedToParseEgsId); + } + + var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions + { + AccountId = authLoginCallbackInfo.LocalUserId + }; + + var tokenCopyResult = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var tokenNullable); + if (tokenCopyResult != Epic.OnlineServices.Result.Success) + { + Result.Failure(EosInterface.Login.LoginError.FailedToGetEgsIdToken); + } + if (tokenNullable is not { } token) { return Result.Failure(EosInterface.Login.LoginError.FailedToGetEgsIdToken); } + + return Result.Success( + (Either)new LoginParams( + Credentials: new Epic.OnlineServices.Connect.Credentials + { + Token = token.JsonWebToken, + Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken + }, + ExternalAccountId: externalAccountId)); + } + + public static async Task, EosInterface.Login.LoginError>> LoginSteam() + { + var credentialsSteamResult = await GenCredentialsSteam(); + if (credentialsSteamResult.TryUnwrapFailure(out var error)) + { + return Result.Failure(error); + } + if (!credentialsSteamResult.TryUnwrapSuccess(out var loginParams)) + { + return Result.Failure(EosInterface.Login.LoginError.InvalidUser); + } + + var result = await Login(loginParams); + if (steamworksAuthTicket.TryUnwrap(out var ticket)) { ticket.Cancel(); } + steamworksAuthTicket = Option.None; + return result; + } + + public static async Task, EosInterface.Login.LoginError>> LoginEpicWithLinkedSteamAccount(EosInterface.Login.LoginEpicFlags flags) + { + if (steamworksAuthTicket.TryUnwrap(out var oldTicket)) { oldTicket.Cancel(); } + var newTicketNullable = await Steamworks.SteamUser.GetAuthTicketForWebApi(EosLoginSteamIdentity); + if (newTicketNullable is not { Data: not null } ticket) + { + return Result.Failure(EosInterface.Login.LoginError.FailedToGetSteamSessionTicket); + } + var epicCredentialsOption = await GenCredentialsEpic( + credentialsType: Epic.OnlineServices.Auth.LoginCredentialType.ExternalAuth, + credentialsId: null, + credentialsToken: ToolBoxCore.ByteArrayToHexString(ticket.Data), + credentialsExternalType: Epic.OnlineServices.ExternalCredentialType.SteamSessionTicket, + flags: flags); + if (epicCredentialsOption.TryUnwrapFailure(out var epicCredentialsFail)) + { + return Result.Failure(epicCredentialsFail); + } + if (!epicCredentialsOption.TryUnwrapSuccess(out var loginParamsOrContinuanceToken)) + { + return Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition); + } + + if (loginParamsOrContinuanceToken.TryGet(out Epic.OnlineServices.ContinuanceToken continuanceToken)) + { + return Result.Success((OneOf) + new EosInterface.EgsAuthContinuanceToken(continuanceToken.InnerHandle, ExtractExpiryTimeFromContinuanceToken(continuanceToken, EosInterface.EgsAuthContinuanceToken.Duration))); + } + if (!loginParamsOrContinuanceToken.TryGet(out LoginParams loginParams)) + { + return Result.Failure(EosInterface.Login.LoginError.UnexpectedContinuanceToken); + } + + var loginResult = await Login(loginParams); + if (loginResult.TryUnwrapSuccess(out var loginSuccess)) + { + return loginSuccess.TryGet(out EosInterface.EosConnectContinuanceToken eosContinuanceToken) + ? Result.Success((OneOf)eosContinuanceToken) + : loginSuccess.TryGet(out EosInterface.ProductUserId puid) + ? Result.Success((OneOf)puid) + : Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition); + } + return loginResult.TryUnwrapFailure(out var loginFailure) + ? Result.Failure(loginFailure) + : Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition); + } + + public static async Task, EosInterface.Login.LoginError>> LoginEpicExchangeCode(string exchangeCode) + { + var epicCredentialsOption = await GenCredentialsEpic( + credentialsType: Epic.OnlineServices.Auth.LoginCredentialType.ExchangeCode, + credentialsId: "", + credentialsToken: exchangeCode, + credentialsExternalType: Epic.OnlineServices.ExternalCredentialType.Epic, + flags: EosInterface.Login.LoginEpicFlags.None); + if (epicCredentialsOption.TryUnwrapFailure(out var epicCredentialsFail)) + { + return Result.Failure(epicCredentialsFail); + } + if (!epicCredentialsOption.TryUnwrapSuccess(out var loginParamsOrContinuanceToken)) + { + return Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition); + } + if (!loginParamsOrContinuanceToken.TryGet(out LoginParams loginParams)) + { + return Result.Failure(EosInterface.Login.LoginError.UnexpectedContinuanceToken); + } + + var result = await Login(loginParams); + return result; + } + + public static async Task, EosInterface.Login.LoginError>> LoginEpicIdToken(EosInterface.EgsIdToken egsIdToken) + { + if (egsIdToken is not EgsIdTokenPrivate privateEgsIdToken) { return Result.Failure(EosInterface.Login.LoginError.InvalidUser); } + var credentials = new Epic.OnlineServices.Connect.Credentials + { + Token = privateEgsIdToken.InternalToken.JsonWebToken, + Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken + }; + + return await Login(new LoginParams(credentials, privateEgsIdToken.AccountId)); + } + + private static DateTime ExtractExpiryTimeFromContinuanceToken(Epic.OnlineServices.ContinuanceToken continuanceToken, TimeSpan fallbackDuration) + { + // Not the exact expiry time, but it's a pretty close guess should we fail to decode the continuance token + var expiryTime = DateTime.Now + fallbackDuration; + + // This method exists to replace Epic.OnlineServices.ContinuanceToken.ToString because + // the generated code is broken, and I don't want to modify it because we risk undoing + // a fix when we update the SDK. + static string continuanceTokenToString(Epic.OnlineServices.ContinuanceToken continuanceToken) + { + int inOutBufferLength = 1024; + System.IntPtr outBufferAddress = Epic.OnlineServices.Helper.AddAllocation(inOutBufferLength); + + var funcResult = Epic.OnlineServices.Bindings.EOS_ContinuanceToken_ToString(continuanceToken.InnerHandle, outBufferAddress, ref inOutBufferLength); + if (funcResult == Epic.OnlineServices.Result.LimitExceeded) + { + // Buffer wasn't large enough to copy the string. + // inOutBufferLength was updated by the last call to be the actual length required. + // Generate a new buffer and try again. + Epic.OnlineServices.Helper.Dispose(ref outBufferAddress); + outBufferAddress = Epic.OnlineServices.Helper.AddAllocation(inOutBufferLength); + funcResult = Epic.OnlineServices.Bindings.EOS_ContinuanceToken_ToString(continuanceToken.InnerHandle, outBufferAddress, ref inOutBufferLength); + if (funcResult != Epic.OnlineServices.Result.Success) + { + DebugConsoleCore.Log($"EOS_ContinuanceToken_ToString failed with result {funcResult}"); + } + } + + Epic.OnlineServices.Utf8String outBuffer = "EOS_ContinuanceToken_ToString failed"; + if (funcResult == Epic.OnlineServices.Result.Success) + { + Epic.OnlineServices.Helper.Get(outBufferAddress, out outBuffer); + } + Epic.OnlineServices.Helper.Dispose(ref outBufferAddress); + + return outBuffer; + } + + var ctDecode = JsonWebToken.Parse(continuanceTokenToString(continuanceToken)); + if (ctDecode.TryUnwrap(out var jwt)) + { + string decodedPayload = jwt.PayloadDecoded; + try + { + // Ugly regex hack to get expiry time. The right thing to do would be to parse the payload as JSON, + // but I don't really care because we're extracting one field out of this whole thing. + string expiryTimeUnix = Regex.Match(decodedPayload, @"""exp""\s*:\s*([0-9]+)").Groups[1].Value; + expiryTime = UnixTime.ParseUtc(expiryTimeUnix).Fallback(UnixTime.UtcEpoch).ToLocalTime(); + } + catch + { + // could not extract expiry time, oh well! + } + } + + return expiryTime; + } + + private static async Task, EosInterface.Login.LoginError>> Login(LoginParams loginParams) + { + static Result, EosInterface.Login.LoginError> success(EosInterface.ProductUserId id) + => Result, EosInterface.Login.LoginError>.Success(id); + static Result, EosInterface.Login.LoginError> continuance(EosInterface.EosConnectContinuanceToken token) + => Result, EosInterface.Login.LoginError>.Success(token); + static Result, EosInterface.Login.LoginError> failure(EosInterface.Login.LoginError error) + => Result, EosInterface.Login.LoginError>.Failure(error); + + if (CorePrivate.ConnectInterface is not { } connectInterface) { return failure(EosInterface.Login.LoginError.EosNotInitialized); } + + var loginOptions = new Epic.OnlineServices.Connect.LoginOptions + { + Credentials = loginParams.Credentials, + UserLoginInfo = null + }; + AccountId primaryExternalId = loginParams.ExternalAccountId; + + var loginWaiter = new CallbackWaiter(); + connectInterface.Login(options: ref loginOptions, clientData: null, completionDelegate: loginWaiter.OnCompletion); + var callbackResultOption = await loginWaiter.Task; + if (!callbackResultOption.TryUnwrap(out var callbackResult)) + { + return failure(EosInterface.Login.LoginError.Timeout); + } + + if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success) + { + var retVal = new EosInterface.ProductUserId(callbackResult.LocalUserId.ToString()); + PuidToPrimaryExternalId[retVal] = primaryExternalId; + return success(retVal); + } + + if (callbackResult is { ResultCode: Epic.OnlineServices.Result.InvalidUser, ContinuanceToken: { } continuanceToken }) + { + var expiryTime = ExtractExpiryTimeFromContinuanceToken(continuanceToken, EosInterface.EosConnectContinuanceToken.Duration); + + return continuance(new EosInterface.EosConnectContinuanceToken(callbackResult.ContinuanceToken.InnerHandle, primaryExternalId, expiryTime)); + } + + return callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.InvalidUser + => failure(EosInterface.Login.LoginError.InvalidUser), + Epic.OnlineServices.Result.AccessDenied + => failure(EosInterface.Login.LoginError.EosAccessDenied), + var unhandled + => failure(unhandled.FailAndLogUnhandledError(EosInterface.Login.LoginError.UnhandledFailureCondition)) + }; + } + + private static void OnEgsAuthStatusChanged(ref Epic.OnlineServices.Auth.LoginStatusChangedCallbackInfo info) + { + var eaidOption = EpicAccountId.Parse(info.LocalUserId.ToString()); + if (!eaidOption.TryUnwrap(out var eaid)) { return; } + + if (info.CurrentStatus == Epic.OnlineServices.LoginStatus.NotLoggedIn) + { + TaskPool.Add( + "UnlogPuidLinkedToEaid", + IdQueriesPrivate.GetPuidForExternalId(eaid), + t => + { + if (!t.TryGetResult(out Result? result)) { return; } + if (!result.TryUnwrapSuccess(out var puid)) { return; } + + MarkAsInaccessible(puid); + }); + } + } + + public static void OnConnectExpiration(ref Epic.OnlineServices.Connect.AuthExpirationCallbackInfo info) + { + var puid = new EosInterface.ProductUserId(info.LocalUserId.ToString()); + DebugConsoleCore.Log($"OnAuthExpirationNotification {puid}"); + if (!PuidToPrimaryExternalId.TryGetValue(puid, out var externalId)) { return; } + + switch (externalId) + { + case SteamId: + { + static async Task RelogSteam() + { + var steamCredentialsResult = await GenCredentialsSteam(); + if (!steamCredentialsResult.TryUnwrapSuccess(out var loginParams)) { return; } + await Relog(loginParams); + } + + TaskPool.Add( + "EosReLoginSteam", + RelogSteam(), + TaskPool.IgnoredCallback); + break; + } + case EpicAccountId epicAccountId: + { + if (CopyEpicIdToken(epicAccountId).TryUnwrap(out var token)) + { + var epicLoginCredentials = new Epic.OnlineServices.Connect.Credentials + { + Token = token.JsonWebToken, + Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken + }; + var reLogParams = new LoginParams(Credentials: epicLoginCredentials, ExternalAccountId: externalId); + TaskPool.Add("OnAuthExpirationNotification", Relog(reLogParams), onCompletion: TaskPool.IgnoredCallback); + } + + break; + } + } + + static async Task Relog(LoginParams loginParams) + { + var loginOptions = new Epic.OnlineServices.Connect.LoginOptions + { + Credentials = loginParams.Credentials, + UserLoginInfo = null + }; + + var connectLoginWaiter = new CallbackWaiter(); + CorePrivate.ConnectInterface?.Login(options: ref loginOptions, clientData: null, completionDelegate: connectLoginWaiter.OnCompletion); + var resultOption = await connectLoginWaiter.Task; + if (resultOption.TryUnwrap(out var result)) + { + string s = $"EOS relog result: {result.ResultCode}"; + if (result.LocalUserId != null) + { + s += " : " + result.LocalUserId; + } + + if (result.ContinuanceToken != null) + { + s += " ; " + result.ContinuanceToken; + } + DebugConsoleCore.Log(s); + } + else + { + DebugConsoleCore.Log("EOS relog timed out"); + } + } + } + + private static void OnConnectStatusChanged(ref Epic.OnlineServices.Connect.LoginStatusChangedCallbackInfo info) + { + var puid = new EosInterface.ProductUserId(info.LocalUserId.ToString()); + DebugConsoleCore.Log($"OnLoginStatusChangedNotification {puid} {info.CurrentStatus}"); + if (info.CurrentStatus == Epic.OnlineServices.LoginStatus.NotLoggedIn) + { + PuidToPrimaryExternalId.TryRemove(puid, out _); + } + } + + public static async Task> LinkExternalAccountToEpicAccount(EosInterface.EgsAuthContinuanceToken continuanceToken) + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.EosNotInitialized); } + + var linkOptions = new Epic.OnlineServices.Auth.LinkAccountOptions + { + LinkAccountFlags = Epic.OnlineServices.Auth.LinkAccountFlags.NoFlags, + ContinuanceToken = new Epic.OnlineServices.ContinuanceToken(continuanceToken.Spend()), + LocalUserId = null + }; + + var callbackWaiter = new CallbackWaiter(timeout: TimeSpan.FromMinutes(5)); + egsAuthInterface.LinkAccount(options: ref linkOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion); + var resultOption = await callbackWaiter.Task; + + if (!resultOption.TryUnwrap(out var result)) + { + return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.TimedOut); + } + + if (result.ResultCode == Epic.OnlineServices.Result.Success) + { + if (!EpicAccountId.Parse(result.SelectedAccountId.ToString()).TryUnwrap(out var epicAccountId)) + { + return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.FailedToParseEgsAccountId); + } + return Result.Success(epicAccountId); + } + return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.UnhandledErrorCondition); + } + + public static async Task> LogoutEpicAccount(EpicAccountId egsId) + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LogoutEpicAccountError.EosNotInitialized); } + + var logoutOptions = new Epic.OnlineServices.Auth.LogoutOptions + { + LocalUserId = Epic.OnlineServices.EpicAccountId.FromString(egsId.EosStringRepresentation) + }; + + var callbackWaiter = new CallbackWaiter(); + egsAuthInterface.Logout(options: ref logoutOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion); + var logoutResultOption = await callbackWaiter.Task; + if (!logoutResultOption.TryUnwrap(out var logoutResult)) + { + return Result.Failure(EosInterface.Login.LogoutEpicAccountError.TimedOut); + } + if (logoutResult.ResultCode == Epic.OnlineServices.Result.Success) { return Result.Success(Unit.Value); } + + return Result.Failure(logoutResult.ResultCode switch + { + _ + => EosInterface.Login.LogoutEpicAccountError.UnhandledErrorCondition + }); + } + + public static void MarkAsInaccessible(EosInterface.ProductUserId puid) + { + PuidToPrimaryExternalId.TryRemove(puid, out _); + } + + private static Option CopyEpicIdToken(EpicAccountId epicAccountId) + { + if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Option.None; } + + var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions + { + AccountId = Epic.OnlineServices.EpicAccountId.FromString(epicAccountId.EosStringRepresentation) + }; + var result = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var tokenNullable); + + if (result is Epic.OnlineServices.Result.Success && tokenNullable is { } token) + { + return Option.Some(token); + } + + return Option.None; + } + + public static async Task> CreateProductAccount(EosInterface.EosConnectContinuanceToken eosContinuanceToken) + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.CreateProductAccountError.EosNotInitialized); } + if (eosContinuanceToken is not { IsValid: true }) { return Result.Failure(EosInterface.Login.CreateProductAccountError.InvalidContinuanceToken); } + + var internalContinuanceToken = new Epic.OnlineServices.ContinuanceToken(eosContinuanceToken.Spend()); + var options = new Epic.OnlineServices.Connect.CreateUserOptions + { + ContinuanceToken = internalContinuanceToken + }; + + var createUserWaiter = new CallbackWaiter(); + connectInterface.CreateUser(options: ref options, clientData: null, completionDelegate: createUserWaiter.OnCompletion); + var callbackResultOption = await createUserWaiter.Task; + if (!callbackResultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.Login.CreateProductAccountError.Timeout); + } + + if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success) + { + var retVal = new EosInterface.ProductUserId(callbackResult.LocalUserId.ToString()); + PuidToPrimaryExternalId[retVal] = eosContinuanceToken.ExternalAccountId; + return Result.Success(retVal); + } + + return Result.Failure(EosInterface.Login.CreateProductAccountError.UnhandledErrorCondition); + } + + public static async Task> LinkExternalAccount(EosInterface.ProductUserId puid, EosInterface.EosConnectContinuanceToken eosContinuanceToken) + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.LinkExternalAccountError.EosNotInitialized); } + if (eosContinuanceToken is not { IsValid: true }) { return Result.Failure(EosInterface.Login.LinkExternalAccountError.InvalidContinuanceToken); } + + var internalContinuanceToken = new Epic.OnlineServices.ContinuanceToken(eosContinuanceToken.Spend()); + var internalPuid = Epic.OnlineServices.ProductUserId.FromString(puid.Value); + var options = new Epic.OnlineServices.Connect.LinkAccountOptions + { + LocalUserId = internalPuid, + ContinuanceToken = internalContinuanceToken + }; + + var linkAccountAwaiter = new CallbackWaiter(); + connectInterface.LinkAccount(options: ref options, clientData: null, completionDelegate: linkAccountAwaiter.OnCompletion); + var callbackResultOption = await linkAccountAwaiter.Task; + if (!callbackResultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.Login.LinkExternalAccountError.Timeout); + } + + if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success) + { + return Result.Success(Unit.Value); + } + + return Result.Failure(callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.ConnectLinkAccountFailed + => EosInterface.Login.LinkExternalAccountError.CannotLink, + _ + => EosInterface.Login.LinkExternalAccountError.UnhandledErrorCondition + }); + } + + public static async Task> UnlinkExternalAccount(EosInterface.ProductUserId puid) + { + if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.UnlinkExternalAccountError.EosNotInitialized); } + + var internalPuid = Epic.OnlineServices.ProductUserId.FromString(puid.Value); + var options = new Epic.OnlineServices.Connect.UnlinkAccountOptions + { + LocalUserId = internalPuid + }; + + var unlinkAccountAwaiter = new CallbackWaiter(); + connectInterface.UnlinkAccount(options: ref options, clientData: null, completionDelegate: unlinkAccountAwaiter.OnCompletion); + var callbackResultOption = await unlinkAccountAwaiter.Task; + if (!callbackResultOption.TryUnwrap(out var callbackResult)) + { + return Result.Failure(EosInterface.Login.UnlinkExternalAccountError.Timeout); + } + + if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success) + { + PuidToPrimaryExternalId.TryRemove(puid, out _); + return Result.Success(Unit.Value); + } + + return Result.Failure(callbackResult.ResultCode switch + { + Epic.OnlineServices.Result.InvalidUser + => EosInterface.Login.UnlinkExternalAccountError.InvalidUser, + _ + => EosInterface.Login.UnlinkExternalAccountError.UnhandledErrorCondition + }); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> CreateProductAccount(EosInterface.EosConnectContinuanceToken eosContinuanceToken) + => TaskScheduler.Schedule(() => LoginPrivate.CreateProductAccount(eosContinuanceToken)); + + public override Task> LinkExternalAccount(EosInterface.ProductUserId puid, EosInterface.EosConnectContinuanceToken eosContinuanceToken) + => TaskScheduler.Schedule(() => LoginPrivate.LinkExternalAccount(puid, eosContinuanceToken)); + + public override Task> UnlinkExternalAccount(EosInterface.ProductUserId puid) + => TaskScheduler.Schedule(() => LoginPrivate.UnlinkExternalAccount(puid)); + + public override Task, EosInterface.Login.LoginError>> LoginEpicWithLinkedSteamAccount(EosInterface.Login.LoginEpicFlags flags) + => TaskScheduler.Schedule(() => LoginPrivate.LoginEpicWithLinkedSteamAccount(flags)); + + public override Task, EosInterface.Login.LoginError>> LoginEpicExchangeCode(string exchangeCode) + => TaskScheduler.Schedule(() => LoginPrivate.LoginEpicExchangeCode(exchangeCode)); + + public override Task, EosInterface.Login.LoginError>> LoginEpicIdToken(EosInterface.EgsIdToken token) + => TaskScheduler.Schedule(() => LoginPrivate.LoginEpicIdToken(token)); + + public override Task, EosInterface.Login.LoginError>> LoginSteam() + => TaskScheduler.Schedule(LoginPrivate.LoginSteam); + + public override Task> LinkExternalAccountToEpicAccount(EosInterface.EgsAuthContinuanceToken continuanceToken) + => TaskScheduler.Schedule(() => LoginPrivate.LinkExternalAccountToEpicAccount(continuanceToken)); + + public override Task> LogoutEpicAccount(EpicAccountId egsId) + => LoginPrivate.LogoutEpicAccount(egsId); + + public override void MarkAsInaccessible(EosInterface.ProductUserId puid) + => LoginPrivate.MarkAsInaccessible(puid); + + public override void TestEosSessionTimeoutRecovery(EosInterface.ProductUserId puid) + { + var info = new Epic.OnlineServices.Connect.AuthExpirationCallbackInfo + { + ClientData = null, + LocalUserId = Epic.OnlineServices.ProductUserId.FromString(puid.Value) + }; + LoginPrivate.OnConnectExpiration(ref info); + } +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/OwnershipPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/OwnershipPrivate.cs new file mode 100644 index 000000000..9217754e0 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/IdAndAuth/OwnershipPrivate.cs @@ -0,0 +1,158 @@ +#nullable enable + +using System; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Barotrauma.Networking; +using Barotrauma; + +namespace EosInterfacePrivate; + +static partial class OwnershipPrivate +{ + internal static async Task> GetGameOwnershipToken(EpicAccountId selfEpicAccountId) + { + if (CorePrivate.EcomInterface is not { } ecomInterface) { return Option.None; } + + var epicAccountIdInternal = + Epic.OnlineServices.EpicAccountId.FromString(selfEpicAccountId.EosStringRepresentation); + + var queryOwnershipTokenOptions = new Epic.OnlineServices.Ecom.QueryOwnershipTokenOptions + { + LocalUserId = epicAccountIdInternal, + CatalogItemIds = new Epic.OnlineServices.Utf8String[] + { + AudienceItemId, + //"Completely arbitrary string!" + + // IDEA: + // As of 2023-06-21, QueryOwnershipToken will succeed even if given obviously fake catalog item IDs. + // This could be useful to us! We could use this to add an audience parameter to this method and fix + // the impersonation exploit without requiring our own persistent service. + // We should ask Epic about this before actually trying it, this is certainly a hack and might get patched. + } + }; + var callbackWaiter = new CallbackWaiter(); + ecomInterface.QueryOwnershipToken(options: ref queryOwnershipTokenOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion); + var queryOwnershipTokenResultOption = await callbackWaiter.Task; + if (!queryOwnershipTokenResultOption.TryUnwrap(out var queryOwnershipTokenResult)) { return Option.None; } + if (queryOwnershipTokenResult.ResultCode != Epic.OnlineServices.Result.Success) { return Option.None; } + + var jwtOption = JsonWebToken.Parse(queryOwnershipTokenResult.OwnershipToken); + return jwtOption.Select(jwt => new EosInterface.Ownership.Token(jwt)); + } + + internal static async Task> VerifyGameOwnershipToken(EosInterface.Ownership.Token token) + { + JsonWebToken jwt = token.Jwt; + + // Decode header + string kidProperty; + string algProperty; + try + { + var jsonDoc = JsonDocument.Parse(jwt.HeaderDecoded); + kidProperty = jsonDoc.RootElement.GetProperty("kid").GetString() ?? ""; + algProperty = jsonDoc.RootElement.GetProperty("alg").GetString() ?? ""; + } + catch + { + // Header JSON decode failed, can't verify token + return Option.None; + } + + // Basic header sanity checks + if (algProperty != "RS512") { return Option.None; } + if (!kidProperty.IsBase64Url()) { return Option.None; } + + // Decode payload + string epicAccountIdStr; + string catalogItemId; + Option expirationOption; + bool owned; + try + { + var jsonDoc = JsonDocument.Parse(jwt.PayloadDecoded); + epicAccountIdStr = jsonDoc.RootElement.GetProperty("sub").GetString() ?? ""; + var entProperty = jsonDoc.RootElement.GetProperty("ent").EnumerateArray().First(); + catalogItemId = entProperty.GetProperty("catalogItemId").GetString() ?? ""; + expirationOption = UnixTime.ParseUtc(jsonDoc.RootElement.GetProperty("exp").GetUInt64().ToString()); + owned = entProperty.GetProperty("owned").GetBoolean(); + } + catch + { + // Payload JSON decode failed, can't verify token + return Option.None; + } + + // Check that the payload is actually what we want + if (catalogItemId != AudienceItemId) { return Option.None; } + if (!owned) { return Option.None; } + if (!expirationOption.TryUnwrap(out var expiration)) { return Option.None; } + if (DateTime.UtcNow >= expiration) { return Option.None; } + + // Get the public key required to verify this token + string modulus; + string exponent; + try + { + string url = + "https://ecommerceintegration-public-service-ecomprod02.ol.epicgames.com/ecommerceintegration/api/public/publickeys/" + + kidProperty; + using var httpClient = new HttpClient(); + var response = await httpClient.SendAsync(new HttpRequestMessage( + HttpMethod.Get, + new Uri(url))); + if (!response.IsSuccessStatusCode) { return Option.None; } + var responseStr = await response.Content.ReadAsStringAsync(); + + var responseJsonDoc = JsonDocument.Parse(responseStr); + if (kidProperty != responseJsonDoc.RootElement.GetProperty("kid").GetString()) { return Option.None; } + + modulus = responseJsonDoc.RootElement.GetProperty("n").GetString() ?? ""; + exponent = responseJsonDoc.RootElement.GetProperty("e").GetString() ?? ""; + } + catch + { + // Failed to query EG Ecom web API, can't verify token + return Option.None; + } + + // Prepare RSA-SHA512 and verify token + var modulusBytesOption = Base64Url.DecodeBytes(modulus); + if (!modulusBytesOption.TryUnwrap(out var modulusBytes)) { return Option.None; } + var exponentBytesOption = Base64Url.DecodeBytes(exponent); + if (!exponentBytesOption.TryUnwrap(out var exponentBytes)) { return Option.None; } + + var signatureBytesOption = Base64Url.DecodeBytes(jwt.Signature); + if (!signatureBytesOption.TryUnwrap(out var signatureBytes)) { return Option.None; } + + using var rsa = RSA.Create(); + using var sha = SHA512.Create(); + rsa.ImportParameters(new RSAParameters + { + Exponent = exponentBytes.ToArray(), + Modulus = modulusBytes.ToArray(), + }); + byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(jwt.Header + "." + jwt.Payload)); + var deformatter = new RSAPKCS1SignatureDeformatter(rsa); + deformatter.SetHashAlgorithm("SHA512"); + bool verified = deformatter.VerifySignature(hash, signatureBytes.ToArray()); + if (!verified) { return Option.None; } + + return EpicAccountId.Parse(epicAccountIdStr); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> GetGameOwnershipToken(EpicAccountId selfEpicAccountId) + => TaskScheduler.Schedule(() => OwnershipPrivate.GetGameOwnershipToken(selfEpicAccountId)); + + public override Task> VerifyGameOwnershipToken(EosInterface.Ownership.Token token) + => TaskScheduler.Schedule(() => OwnershipPrivate.VerifyGameOwnershipToken(token)); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/P2P/P2PSocketPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/P2P/P2PSocketPrivate.cs new file mode 100644 index 000000000..4ba5c1322 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/P2P/P2PSocketPrivate.cs @@ -0,0 +1,252 @@ +#nullable enable +using Barotrauma.Networking; +using System; +using System.Collections.Generic; +using Barotrauma; + +namespace EosInterfacePrivate; + +public sealed class P2PSocketPrivate : EosInterface.P2PSocket +{ + private readonly record struct CallbackIds( + ulong OnConnectionRequested, + ulong OnConnectionClosed); + private CallbackIds callbackIds; + + private readonly Epic.OnlineServices.P2P.SocketId socketIdInternal; + private readonly Epic.OnlineServices.ProductUserId selfPuid; + private P2PSocketPrivate(Epic.OnlineServices.P2P.SocketId socketIdInternal, Epic.OnlineServices.ProductUserId selfPuid) + { + this.socketIdInternal = socketIdInternal; + this.selfPuid = selfPuid; + } + + internal static Result CreatePrivate(EosInterface.ProductUserId selfPuid, EosInterface.SocketId socketId) + { + var p2pInterface = CorePrivate.P2PInterface; + if (p2pInterface is null) { return Result.Failure(CreationError.EosNotInitialized); } + + var socketIdInternal = new Epic.OnlineServices.P2P.SocketId { SocketName = socketId.SocketName }; + var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value); + + using var janitor = Janitor.Start(); + + var socket = new P2PSocketPrivate(socketIdInternal, selfPuidInternal); + + var addNotifyPeerConnectionRequestOptions = new Epic.OnlineServices.P2P.AddNotifyPeerConnectionRequestOptions + { + LocalUserId = selfPuidInternal, + SocketId = socketIdInternal + }; + + var onConnectionRequestCallbackId = p2pInterface.AddNotifyPeerConnectionRequest( + ref addNotifyPeerConnectionRequestOptions, + socket, + ConnectionRequestHandler); + + if (onConnectionRequestCallbackId == Epic.OnlineServices.Common.InvalidNotificationid) + { + return Result.Failure(CreationError.RequestBindFailed); + } + + janitor.AddAction(() => p2pInterface.RemoveNotifyPeerConnectionRequest(onConnectionRequestCallbackId)); + + var addNotifyPeerConnectionClosedOptions = new Epic.OnlineServices.P2P.AddNotifyPeerConnectionClosedOptions + { + LocalUserId = selfPuidInternal, + SocketId = socketIdInternal + }; + + var onConnectionClosedCallbackId = p2pInterface.AddNotifyPeerConnectionClosed( + ref addNotifyPeerConnectionClosedOptions, + socket, + ConnectionClosedHandler); + + if (onConnectionClosedCallbackId == Epic.OnlineServices.Common.InvalidNotificationid) + { + return Result.Failure(CreationError.CloseBindFailed); + } + + janitor.AddAction(() => p2pInterface.RemoveNotifyPeerConnectionClosed(onConnectionClosedCallbackId)); + + socket.callbackIds = new CallbackIds( + OnConnectionRequested: onConnectionRequestCallbackId, + OnConnectionClosed: onConnectionClosedCallbackId); + + janitor.Dismiss(); + + return Result.Success(socket); + } + + private static void ConnectionRequestHandler(ref Epic.OnlineServices.P2P.OnIncomingConnectionRequestInfo info) + { + if (info.ClientData is P2PSocketPrivate p2pSocket + && string.Equals(info.SocketId?.SocketName, p2pSocket.socketIdInternal.SocketName)) + { + p2pSocket.HandleIncomingConnection.Invoke(new IncomingConnectionRequest( + Socket: p2pSocket, + RemoteUserId: new EosInterface.ProductUserId(info.RemoteUserId.ToString()))); + } + } + + private static void ConnectionClosedHandler(ref Epic.OnlineServices.P2P.OnRemoteConnectionClosedInfo info) + { + if (info.ClientData is P2PSocketPrivate p2pSocket + && string.Equals(info.SocketId?.SocketName, p2pSocket.socketIdInternal.SocketName)) + { + p2pSocket.HandleClosedConnection.Invoke(new RemoteConnectionClosed( + RemoteUserId: new EosInterface.ProductUserId(info.RemoteUserId.ToString()), + Reason: info.Reason switch + { + Epic.OnlineServices.P2P.ConnectionClosedReason.Unknown + => RemoteConnectionClosed.ConnectionClosedReason.Unknown, + Epic.OnlineServices.P2P.ConnectionClosedReason.ClosedByLocalUser + => RemoteConnectionClosed.ConnectionClosedReason.ClosedByLocalUser, + Epic.OnlineServices.P2P.ConnectionClosedReason.ClosedByPeer + => RemoteConnectionClosed.ConnectionClosedReason.ClosedByPeer, + Epic.OnlineServices.P2P.ConnectionClosedReason.TimedOut + => RemoteConnectionClosed.ConnectionClosedReason.TimedOut, + Epic.OnlineServices.P2P.ConnectionClosedReason.TooManyConnections + => RemoteConnectionClosed.ConnectionClosedReason.TooManyConnections, + Epic.OnlineServices.P2P.ConnectionClosedReason.InvalidMessage + => RemoteConnectionClosed.ConnectionClosedReason.InvalidMessage, + Epic.OnlineServices.P2P.ConnectionClosedReason.InvalidData + => RemoteConnectionClosed.ConnectionClosedReason.InvalidData, + Epic.OnlineServices.P2P.ConnectionClosedReason.ConnectionFailed + => RemoteConnectionClosed.ConnectionClosedReason.ConnectionFailed, + Epic.OnlineServices.P2P.ConnectionClosedReason.ConnectionClosed + => RemoteConnectionClosed.ConnectionClosedReason.ConnectionClosed, + Epic.OnlineServices.P2P.ConnectionClosedReason.NegotiationFailed + => RemoteConnectionClosed.ConnectionClosedReason.NegotiationFailed, + Epic.OnlineServices.P2P.ConnectionClosedReason.UnexpectedError + => RemoteConnectionClosed.ConnectionClosedReason.UnexpectedError, + _ + => RemoteConnectionClosed.ConnectionClosedReason.Unhandled + })); + } + } + + public override void AcceptConnectionRequest(IncomingConnectionRequest request) + { + var remoteUserIdInternal = Epic.OnlineServices.ProductUserId.FromString(request.RemoteUserId.Value); + + var acceptConnectionOptions = new Epic.OnlineServices.P2P.AcceptConnectionOptions + { + LocalUserId = selfPuid, + RemoteUserId = remoteUserIdInternal, + SocketId = socketIdInternal + }; + CorePrivate.P2PInterface?.AcceptConnection(ref acceptConnectionOptions); + } + + public override void CloseConnection(EosInterface.ProductUserId remoteUserId) + { + var remoteUserIdInternal = Epic.OnlineServices.ProductUserId.FromString(remoteUserId.Value); + + var closeConnectionOptions = new Epic.OnlineServices.P2P.CloseConnectionOptions + { + LocalUserId = selfPuid, + RemoteUserId = remoteUserIdInternal, + SocketId = socketIdInternal + }; + CorePrivate.P2PInterface?.CloseConnection(ref closeConnectionOptions); + } + + public override IEnumerable GetMessageBatch() + { + var p2pInterface = CorePrivate.P2PInterface; + if (p2pInterface is null) { yield break; } + + var packetQueueOptions = new Epic.OnlineServices.P2P.GetPacketQueueInfoOptions(); + p2pInterface.GetPacketQueueInfo(ref packetQueueOptions, out var packetQueueInfo); + + byte[] buf = new byte[Epic.OnlineServices.P2P.P2PInterface.MaxPacketSize]; + + for (ulong i = 0; i < packetQueueInfo.IncomingPacketQueueCurrentPacketCount; i++) + { + var receivePacketOptions = new Epic.OnlineServices.P2P.ReceivePacketOptions + { + LocalUserId = selfPuid, + MaxDataSizeBytes = (uint)buf.Length, + RequestedChannel = null + }; + + var result = p2pInterface.ReceivePacket( + ref receivePacketOptions, + out var senderId, + out var senderSocketId, + out _, + buf, + out uint bytesWritten); + + if (result != Epic.OnlineServices.Result.Success) { continue; } + if (senderSocketId.SocketName != socketIdInternal.SocketName) { continue; } + + yield return new IncomingMessage( + buf, (int)bytesWritten, new EosInterface.ProductUserId(senderId.ToString())); + } + } + + public override Result SendMessage(OutgoingMessage msg) + { + var p2pInterface = CorePrivate.P2PInterface; + if (p2pInterface is null) { return Result.Failure(SendError.EosNotInitialized); } + + var reliability = msg.DeliveryMethod switch + { + DeliveryMethod.Reliable + => Epic.OnlineServices.P2P.PacketReliability.ReliableOrdered, + _ + => Epic.OnlineServices.P2P.PacketReliability.UnreliableUnordered + }; + + var sendPacketOptions = new Epic.OnlineServices.P2P.SendPacketOptions + { + LocalUserId = selfPuid, + RemoteUserId = Epic.OnlineServices.ProductUserId.FromString(msg.Destination.Value), + SocketId = socketIdInternal, + Channel = 0, + Data = new ArraySegment(array: msg.Buffer, offset: 0, count: msg.ByteLength), + AllowDelayedDelivery = true, + Reliability = reliability, + DisableAutoAcceptConnection = false + }; + var result = p2pInterface.SendPacket(ref sendPacketOptions); + + return result switch + { + Epic.OnlineServices.Result.Success + => Result.Success(Unit.Value), + Epic.OnlineServices.Result.InvalidParameters + => Result.Failure(SendError.InvalidParameters), + Epic.OnlineServices.Result.LimitExceeded + => Result.Failure(SendError.LimitExceeded), + Epic.OnlineServices.Result.NoConnection + => Result.Failure(SendError.NoConnection), + _ + => Result.Failure(SendError.UnhandledErrorCondition) + }; + } + + public override void Dispose() + { + var p2pInterface = CorePrivate.P2PInterface; + if (p2pInterface is null) { return; } + + var closeConnectionsOptions = new Epic.OnlineServices.P2P.CloseConnectionsOptions + { + LocalUserId = selfPuid, + SocketId = socketIdInternal + }; + p2pInterface.RemoveNotifyPeerConnectionRequest(callbackIds.OnConnectionRequested); + p2pInterface.RemoveNotifyPeerConnectionClosed(callbackIds.OnConnectionClosed); + p2pInterface.CloseConnections(ref closeConnectionsOptions); + callbackIds = default; + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Result CreateP2PSocket(EosInterface.ProductUserId puid, EosInterface.SocketId socketId) + => P2PSocketPrivate.CreatePrivate(puid, socketId); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/OwnedSessionsPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/OwnedSessionsPrivate.cs new file mode 100644 index 000000000..fbb20cbce --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/OwnedSessionsPrivate.cs @@ -0,0 +1,319 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class OwnedSessionsPrivate +{ + private static readonly Random rng = new Random(); + private static readonly ConcurrentDictionary liveOwnedSessions = new ConcurrentDictionary(); + + private static Epic.OnlineServices.Utf8String IdentifierToAttributeKey(Identifier id) + { + // Attribute keys are always uppercase in the EOS developer page, + // so to minimize surprises let's match that here + return id.Value.ToUpperInvariant(); + } + + public static async Task> Create(Option selfUserIdOption, Identifier internalId, int maxPlayers) + { + var (success, failure) = Result.GetFactoryMethods(); + + if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return failure(EosInterface.Sessions.CreateError.EosNotInitialized); } + + if (liveOwnedSessions.ContainsKey(internalId)) { return failure(EosInterface.Sessions.CreateError.SessionAlreadyExists); } + + using var janitor = Janitor.Start(); + + var bucketIndex = rng.Next(EosInterface.Sessions.MinBucketIndex, EosInterface.Sessions.MaxBucketIndex + 1); + string bucketName = EosInterface.Sessions.DefaultBucketName + bucketIndex; + var createSessionModificationOptions = new Epic.OnlineServices.Sessions.CreateSessionModificationOptions + { + SessionName = internalId.Value.ToUpperInvariant(), + BucketId = bucketName, + MaxPlayers = (uint)maxPlayers, + LocalUserId = selfUserIdOption.TryUnwrap(out var selfUserId) + ? Epic.OnlineServices.ProductUserId.FromString(selfUserId.Value) + : null, + PresenceEnabled = false, + SessionId = null, + SanctionsEnabled = false + }; + var sessionCreateResult = sessionsInterface.CreateSessionModification(ref createSessionModificationOptions, out var sessionModificationHandle); + if (sessionCreateResult != Epic.OnlineServices.Result.Success) + { + return failure(sessionCreateResult switch + { + Epic.OnlineServices.Result.InvalidUser => EosInterface.Sessions.CreateError.InvalidUser, + Epic.OnlineServices.Result.SessionsSessionAlreadyExists => EosInterface.Sessions.CreateError.SessionAlreadyExists, + _ => EosInterface.Sessions.CreateError.UnhandledErrorCondition + }); + } + janitor.AddAction(sessionModificationHandle.Release); + + var updateSessionOptions = new Epic.OnlineServices.Sessions.UpdateSessionOptions + { + SessionModificationHandle = sessionModificationHandle + }; + + var updateSessionWaiter = new CallbackWaiter(); + sessionsInterface.UpdateSession(options: ref updateSessionOptions, clientData: null, completionDelegate: updateSessionWaiter.OnCompletion); + var updateSessionResultOption = await updateSessionWaiter.Task; + + if (!updateSessionResultOption.TryUnwrap(out var updateSessionResult)) { return failure(EosInterface.Sessions.CreateError.TimedOut); } + + if (updateSessionResult.ResultCode == Epic.OnlineServices.Result.Success) + { + var newSession = new EosInterface.Sessions.OwnedSession( + BucketId: bucketName, + InternalId: updateSessionResult.SessionName.ToIdentifier(), + GlobalId: updateSessionResult.SessionId.ToIdentifier(), + Attributes: new Dictionary()); + liveOwnedSessions.TryAdd(internalId, newSession); + return success(newSession); + } + return failure(updateSessionResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.CreateError.UnhandledErrorCondition)); + } + + public static async Task> UpdateOwnedSessionAttributes(EosInterface.Sessions.OwnedSession session) + { + if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.AttributeUpdateError.EosNotInitialized); } + + using var janitor = Janitor.Start(); + + var updateSessionModificationOptions = new Epic.OnlineServices.Sessions.UpdateSessionModificationOptions + { + SessionName = session.InternalId.Value.ToUpperInvariant() + }; + var sessionCreateResult = sessionsInterface.UpdateSessionModification(ref updateSessionModificationOptions, out var sessionModificationHandle); + if (sessionCreateResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure(EosInterface.Sessions.AttributeUpdateError.FailedToCreateSessionModificationHandle); + } + janitor.AddAction(() => sessionModificationHandle.Release()); + + var keysToRemove = session.SyncedAttributes + .Except(session.Attributes) + .Select(kvp => kvp.Key) + .ToArray(); + + var attributesToAdd = session.Attributes + .Except(session.SyncedAttributes) + .ToArray(); + + var setBucketIdOptions = new Epic.OnlineServices.Sessions.SessionModificationSetBucketIdOptions + { + BucketId = session.BucketId + }; + sessionModificationHandle.SetBucketId(ref setBucketIdOptions); + + if (session.HostAddress.TryUnwrap(out var hostAddress)) + { + var setHostAddressOptions = new Epic.OnlineServices.Sessions.SessionModificationSetHostAddressOptions + { + HostAddress = hostAddress + }; + sessionModificationHandle.SetHostAddress(ref setHostAddressOptions); + } + + foreach (Identifier key in keysToRemove) + { + var removeAttributeOptions = new Epic.OnlineServices.Sessions.SessionModificationRemoveAttributeOptions + { + Key = IdentifierToAttributeKey(key) + }; + var removeResult = sessionModificationHandle.RemoveAttribute(ref removeAttributeOptions); + if (removeResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure( + removeResult switch + { + Epic.OnlineServices.Result.InvalidParameters + => EosInterface.Sessions.AttributeUpdateError.InvalidParametersForRemoveAttribute, + Epic.OnlineServices.Result.IncompatibleVersion + => EosInterface.Sessions.AttributeUpdateError.IncompatibleVersionForRemoveAttribute, + _ + => EosInterface.Sessions.AttributeUpdateError.UnhandledErrorConditionForRemoveAttribute + }); + } + } + + foreach (var kvp in attributesToAdd) + { + // EOS doesn't like empty values so let's skip those + if (kvp.Value.IsNullOrEmpty()) { continue; } + + var addAttributeOptions = new Epic.OnlineServices.Sessions.SessionModificationAddAttributeOptions + { + SessionAttribute = new Epic.OnlineServices.Sessions.AttributeData + { + Key = IdentifierToAttributeKey(kvp.Key), + Value = kvp.Value + }, + AdvertisementType = Epic.OnlineServices.Sessions.SessionAttributeAdvertisementType.Advertise + }; + var addResult = sessionModificationHandle.AddAttribute(ref addAttributeOptions); + if (addResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure( + addResult switch + { + Epic.OnlineServices.Result.InvalidParameters + => EosInterface.Sessions.AttributeUpdateError.InvalidParametersForAddAttribute, + Epic.OnlineServices.Result.IncompatibleVersion + => EosInterface.Sessions.AttributeUpdateError.IncompatibleVersionForAddAttribute, + _ + => EosInterface.Sessions.AttributeUpdateError.UnhandledErrorConditionForAddAttribute + }); + } + } + + var updateSessionOptions = new Epic.OnlineServices.Sessions.UpdateSessionOptions + { + SessionModificationHandle = sessionModificationHandle + }; + + var updateSessionWaiter = new CallbackWaiter(); + sessionsInterface.UpdateSession(options: ref updateSessionOptions, clientData: null, completionDelegate: updateSessionWaiter.OnCompletion); + var updateSessionResultOption = await updateSessionWaiter.Task; + + if (!updateSessionResultOption.TryUnwrap(out var updateSessionResult)) { Result.Failure(EosInterface.Sessions.AttributeUpdateError.TimedOut); } + + if (updateSessionResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return updateSessionResult.ResultCode switch + { + Epic.OnlineServices.Result.InvalidParameters + => Result.Failure(EosInterface.Sessions.AttributeUpdateError.InvalidParametersForSessionUpdate), + Epic.OnlineServices.Result.SessionsOutOfSync + => Result.Failure(EosInterface.Sessions.AttributeUpdateError.SessionsOutOfSync), + Epic.OnlineServices.Result.NotFound + => Result.Failure(EosInterface.Sessions.AttributeUpdateError.SessionNotFound), + Epic.OnlineServices.Result.NoConnection + => Result.Failure(EosInterface.Sessions.AttributeUpdateError.NoConnection), + var unhandled + => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.Sessions.AttributeUpdateError.UnhandledErrorCondition)) + }; + } + + session.SyncedAttributes = session.Attributes.ToImmutableDictionary(); + return Result.Success(Unit.Value); + } + + public static async Task> CloseOwnedSession(EosInterface.Sessions.OwnedSession session) + { + if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.CloseError.EosNotInitialized); } + + liveOwnedSessions.TryRemove(session.InternalId, out _); + + var options = new Epic.OnlineServices.Sessions.DestroySessionOptions + { + SessionName = session.InternalId.Value.ToUpperInvariant() + }; + + var callbackWaiter = new CallbackWaiter(); + sessionsInterface.DestroySession(options: ref options, clientData: null, completionDelegate: callbackWaiter.OnCompletion); + var resultOption = await callbackWaiter.Task; + + if (!resultOption.TryUnwrap(out var result)) { return Result.Failure(EosInterface.Sessions.CloseError.TimedOut); } + + return result.ResultCode switch + { + Epic.OnlineServices.Result.Success + => Result.Success(Unit.Value), + Epic.OnlineServices.Result.InvalidParameters + => Result.Failure(EosInterface.Sessions.CloseError.InvalidParameters), + Epic.OnlineServices.Result.AlreadyPending + => Result.Failure(EosInterface.Sessions.CloseError.AlreadyPending), + Epic.OnlineServices.Result.NotFound + => Result.Failure(EosInterface.Sessions.CloseError.NotFound), + var unhandled + => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.Sessions.CloseError.UnhandledErrorCondition)) + }; + } + + public static Task CloseAllOwnedSessions() + { + return Task.WhenAll(liveOwnedSessions.Values + .ToArray() + .Select(CloseOwnedSession)); + } + + public static Task ForceUpdateAllOwnedSessions() + { + var sessionsToUpdate = liveOwnedSessions.Values.ToArray(); + foreach (var session in sessionsToUpdate) + { + session.SyncedAttributes = ImmutableDictionary.Empty; + } + return Task.WhenAll(sessionsToUpdate + .Select(UpdateOwnedSessionAttributes)); + } + + public static async Task, EosInterface.Sessions.RegisterError>> RegisterPlayers(EosInterface.Sessions.OwnedSession session, params EosInterface.ProductUserId[] puids) + { + if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.RegisterError.EosNotInitialized); } + + var registerPlayersOptions = new Epic.OnlineServices.Sessions.RegisterPlayersOptions + { + SessionName = session.InternalId.Value.ToUpperInvariant(), + PlayersToRegister = puids.Select(puid => Epic.OnlineServices.ProductUserId.FromString(puid.Value)).ToArray() + }; + var registerPlayersWaiter = new CallbackWaiter(); + sessionsInterface.RegisterPlayers(options: ref registerPlayersOptions, clientData: null, completionDelegate: registerPlayersWaiter.OnCompletion); + var registerResultOption = await registerPlayersWaiter.Task; + + if (!registerResultOption.TryUnwrap(out var registerResult)) { return Result.Failure(EosInterface.Sessions.RegisterError.TimedOut); } + + if (registerResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(registerResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.RegisterError.UnhandledErrorCondition)); + } + + return Result.Success(registerResult.RegisteredPlayers.Select(puid => new EosInterface.ProductUserId(puid.ToString())).ToImmutableArray()); + } + + public static async Task, EosInterface.Sessions.UnregisterError>> UnregisterPlayers(EosInterface.Sessions.OwnedSession session, params EosInterface.ProductUserId[] puids) + { + if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.UnregisterError.EosNotInitialized); } + + var unregisterPlayersOptions = new Epic.OnlineServices.Sessions.UnregisterPlayersOptions + { + SessionName = session.InternalId.Value.ToUpperInvariant(), + PlayersToUnregister = puids.Select(puid => Epic.OnlineServices.ProductUserId.FromString(puid.Value)).ToArray() + }; + var unregisterPlayersWaiter = new CallbackWaiter(); + sessionsInterface.UnregisterPlayers(options: ref unregisterPlayersOptions, clientData: null, completionDelegate: unregisterPlayersWaiter.OnCompletion); + var unregisterResultOption = await unregisterPlayersWaiter.Task; + + if (!unregisterResultOption.TryUnwrap(out var unregisterResult)) { return Result.Failure(EosInterface.Sessions.UnregisterError.TimedOut); } + + if (unregisterResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure(unregisterResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.UnregisterError.UnhandledErrorCondition)); + } + + return Result.Success(unregisterResult.UnregisteredPlayers.Select(puid => new EosInterface.ProductUserId(puid.ToString())).ToImmutableArray()); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task> CreateSession(Option selfUserIdOption, Identifier internalId, int maxPlayers) + => TaskScheduler.Schedule(() => OwnedSessionsPrivate.Create(selfUserIdOption, internalId, maxPlayers)); + + public override Task> UpdateOwnedSessionAttributes(EosInterface.Sessions.OwnedSession session) + => TaskScheduler.Schedule(() => OwnedSessionsPrivate.UpdateOwnedSessionAttributes(session)); + + public override Task> CloseOwnedSession(EosInterface.Sessions.OwnedSession session) + => TaskScheduler.Schedule(() => OwnedSessionsPrivate.CloseOwnedSession(session)); + + public override Task CloseAllOwnedSessions() + => TaskScheduler.Schedule(OwnedSessionsPrivate.CloseAllOwnedSessions); +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/RemoteSessionsPrivate.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/RemoteSessionsPrivate.cs new file mode 100644 index 000000000..307d75b7e --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Sessions/RemoteSessionsPrivate.cs @@ -0,0 +1,159 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Barotrauma; + +namespace EosInterfacePrivate; + +static class RemoteSessionsPrivate +{ + /// + /// Largest number that can be passed to CreateSessionSearchOptions.MaxSearchResults + /// before it will immediately result in an InvalidParameters error. + /// + private const uint MaxResultsUpperBound = Epic.OnlineServices.Sessions.SessionsInterface.MaxSearchResults; + + public static async Task, EosInterface.Sessions.RemoteSession.Query.Error>> RunQuery(EosInterface.Sessions.RemoteSession.Query query) + { + if (CorePrivate.SessionsInterface is not { } sessionsInterface) + { + return Result.Failure(EosInterface.Sessions.RemoteSession.Query.Error.EosNotInitialized); + } + + using var janitor = Janitor.Start(); + + var createSessionSearchOptions = new Epic.OnlineServices.Sessions.CreateSessionSearchOptions + { + MaxSearchResults = query.MaxResults + }; + var createSessionSearchResult = sessionsInterface.CreateSessionSearch(ref createSessionSearchOptions, out var sessionSearchHandle); + if (createSessionSearchResult != Epic.OnlineServices.Result.Success) + { + return Result.Failure( + createSessionSearchResult switch + { + Epic.OnlineServices.Result.InvalidParameters when query.MaxResults > MaxResultsUpperBound + => EosInterface.Sessions.RemoteSession.Query.Error.ExceededMaxAllowedResults, + Epic.OnlineServices.Result.InvalidParameters + => EosInterface.Sessions.RemoteSession.Query.Error.InvalidParameters, + _ + => createSessionSearchResult.FailAndLogUnhandledError(EosInterface.Sessions.RemoteSession.Query.Error.UnhandledErrorCondition) + }); + } + janitor.AddAction(sessionSearchHandle.Release); + + var setParameterOptions = new Epic.OnlineServices.Sessions.SessionSearchSetParameterOptions + { + Parameter = new Epic.OnlineServices.Sessions.AttributeData + { + Key = Epic.OnlineServices.Sessions.SessionsInterface.SearchBucketId, + Value = new Epic.OnlineServices.Sessions.AttributeDataValue + { + AsUtf8 = EosInterface.Sessions.DefaultBucketName + query.BucketIndex + } + }, + ComparisonOp = Epic.OnlineServices.ComparisonOp.Equal + }; + sessionSearchHandle.SetParameter(ref setParameterOptions); + + var findOptions = new Epic.OnlineServices.Sessions.SessionSearchFindOptions + { + LocalUserId = Epic.OnlineServices.ProductUserId.FromString(query.LocalUserId.Value) + }; + + var findCallbackWaiter = new CallbackWaiter(); + sessionSearchHandle.Find(options: ref findOptions, clientData: null, completionDelegate: findCallbackWaiter.OnCompletion); + var findResultOption = await findCallbackWaiter.Task; + if (!findResultOption.TryUnwrap(out var findResult)) + { + return Result.Failure(EosInterface.Sessions.RemoteSession.Query.Error.TimedOut); + } + if (findResult.ResultCode != Epic.OnlineServices.Result.Success) + { + return Result.Failure( + findResult.ResultCode switch + { + Epic.OnlineServices.Result.NotFound + => EosInterface.Sessions.RemoteSession.Query.Error.NotFound, + Epic.OnlineServices.Result.InvalidParameters + => EosInterface.Sessions.RemoteSession.Query.Error.InvalidParameters, + _ + => EosInterface.Sessions.RemoteSession.Query.Error.EosNotInitialized + }); + } + + var boilerplate1 = new Epic.OnlineServices.Sessions.SessionSearchGetSearchResultCountOptions(); + uint resultCount = sessionSearchHandle.GetSearchResultCount(ref boilerplate1); + + var sessions = new List(); + foreach (int sessionIndex in Enumerable.Range(0, (int)resultCount)) + { + var attributes = new Dictionary(); + + var copySessionDetailsOptions = new Epic.OnlineServices.Sessions.SessionSearchCopySearchResultByIndexOptions + { + SessionIndex = (uint)sessionIndex + }; + var detailsCopyResult = sessionSearchHandle.CopySearchResultByIndex(ref copySessionDetailsOptions, out var sessionDetails); + if (detailsCopyResult != Epic.OnlineServices.Result.Success) { break; } + janitor.AddAction(sessionDetails.Release); + + var copyInfoOptions = new Epic.OnlineServices.Sessions.SessionDetailsCopyInfoOptions(); + var infoCopyResult = sessionDetails.CopyInfo(ref copyInfoOptions, out var sessionInfo); + if (infoCopyResult != Epic.OnlineServices.Result.Success) { break; } + + if (sessionInfo is not + { + Settings: + { + BucketId: { } bucketId, + NumPublicConnections: var numPublicConnections + }, + NumOpenPublicConnections: var numOpenPublicConnections, + SessionId: { } sessionId, + HostAddress: { } hostAddress + }) + { + break; + } + + var boilerplate2 = new Epic.OnlineServices.Sessions.SessionDetailsGetSessionAttributeCountOptions(); + var attributeCount = sessionDetails.GetSessionAttributeCount(ref boilerplate2); + + foreach (var attributeIndex in Enumerable.Range(0, (int)attributeCount)) + { + var copyAttributeOptions = + new Epic.OnlineServices.Sessions.SessionDetailsCopySessionAttributeByIndexOptions + { + AttrIndex = (uint)attributeIndex + }; + + var attributeCopyResult = sessionDetails.CopySessionAttributeByIndex(ref copyAttributeOptions, out var attributeNullable); + if (attributeCopyResult != Epic.OnlineServices.Result.Success) { break; } + if (attributeNullable?.Data is not { } attributeData + || attributeData.Value.ValueType != Epic.OnlineServices.AttributeType.String) + { + break; + } + attributes.Add(attributeData.Key.ToIdentifier(), attributeData.Value.AsUtf8); + } + sessions.Add(new EosInterface.Sessions.RemoteSession( + SessionId: sessionId, + HostAddress: hostAddress, + CurrentPlayers: (int)(numPublicConnections - numOpenPublicConnections), + MaxPlayers: (int)numPublicConnections, + Attributes: attributes.ToImmutableDictionary(), + BucketId: bucketId)); + } + + return Result.Success(sessions.ToImmutableArray()); + } +} + +internal sealed partial class ImplementationPrivate : EosInterface.Implementation +{ + public override Task, EosInterface.Sessions.RemoteSession.Query.Error>> RunRemoteSessionQuery(EosInterface.Sessions.RemoteSession.Query query) + => TaskScheduler.Schedule(() => RemoteSessionsPrivate.RunQuery(query)); +} \ No newline at end of file diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/CallbackWaiter.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/CallbackWaiter.cs new file mode 100644 index 000000000..677ab874a --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/CallbackWaiter.cs @@ -0,0 +1,50 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using Barotrauma; + +namespace EosInterfacePrivate; + +/// +/// Creates a task that returns the result of a callback. +/// This is meant to be used with EOS' asynchronous methods, +/// which are all callback-based because this is a C library. +/// +internal class CallbackWaiter where T : notnull +{ + private readonly object mutex = new object(); + private Option result = Option.None; + private readonly DateTime timeout; + + public readonly Task> Task; + + public CallbackWaiter(TimeSpan timeout = default) + { + this.timeout = DateTime.Now + (timeout == default + ? TimeSpan.FromSeconds(60) + : timeout); + this.Task = System.Threading.Tasks.Task.Run(RunTask); + } + + public void OnCompletion(ref T result) + { + lock (mutex) + { + this.result = Option.Some(result); + } + } + + private async Task> RunTask() + { + while (DateTime.Now < timeout) + { + lock (mutex) + { + if (result.IsSome()) { return result; } + } + await System.Threading.Tasks.Task.Delay(32); + } + return Option.None; + } +} diff --git a/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/ResultExtension.cs b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/ResultExtension.cs new file mode 100644 index 000000000..a37e80169 --- /dev/null +++ b/Libraries/BarotraumaLibs/EosInterfacePrivate/InterfaceImpl/Util/ResultExtension.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using Barotrauma.Debugging; +using Microsoft.Xna.Framework; + +namespace EosInterfacePrivate; + +public static class ResultExtension +{ + public static T FailAndLogUnhandledError(this Epic.OnlineServices.Result result, T unknown, [CallerMemberName] string caller = null) + { + DebugConsoleCore.NewMessage($"Result \"{result}\" was not handled by \"{caller}\".", Color.Red); + return unknown; + } +} \ No newline at end of file diff --git a/Libraries/Facepunch.Steamworks/SteamServer.cs b/Libraries/Facepunch.Steamworks/SteamServer.cs index d2a264969..e4543dd1e 100644 --- a/Libraries/Facepunch.Steamworks/SteamServer.cs +++ b/Libraries/Facepunch.Steamworks/SteamServer.cs @@ -403,7 +403,7 @@ namespace Steamworks /// /// Forget this guy. They're no longer in the game. /// - public static void EndSession( SteamId steamid ) + public static void EndAuthSession( SteamId steamid ) { Internal?.EndAuthSession( steamid ); } diff --git a/Libraries/Facepunch.Steamworks/SteamUserStats.cs b/Libraries/Facepunch.Steamworks/SteamUserStats.cs index bf0a267a0..b4a4aa4e7 100644 --- a/Libraries/Facepunch.Steamworks/SteamUserStats.cs +++ b/Libraries/Facepunch.Steamworks/SteamUserStats.cs @@ -200,11 +200,11 @@ namespace Steamworks /// to that value. Steam doesn't provide a mechanism for atomically increasing /// stats like this, this functionality is added here as a convenience. ///
- public static bool AddStat( string name, int amount = 1 ) + public static bool AddStatInt( string name, int amount = 1 ) { var val = GetStatInt( name ); val += amount; - return SetStat( name, val ); + return SetStatInt( name, val ); } /// @@ -212,17 +212,17 @@ namespace Steamworks /// to that value. Steam doesn't provide a mechanism for atomically increasing /// stats like this, this functionality is added here as a convenience. /// - public static bool AddStat( string name, float amount = 1.0f ) + public static bool AddStatFloat( string name, float amount = 1.0f ) { var val = GetStatFloat( name ); val += amount; - return SetStat( name, val ); + return SetStatFloat(name, val) || SetStatInt( name, (int)val ); } /// /// Set a stat value. This will automatically call after a successful call. /// - public static bool SetStat( string name, int value ) + public static bool SetStatInt( string name, int value ) { return Internal != null && Internal.SetStat( name, value ); } @@ -230,7 +230,7 @@ namespace Steamworks /// /// Set a stat value. This will automatically call after a successful call. /// - public static bool SetStat( string name, float value ) + public static bool SetStatFloat( string name, float value ) { return Internal != null && Internal.SetStat( name, value ); } @@ -250,9 +250,12 @@ namespace Steamworks ///
public static float GetStatFloat( string name ) { - float data = 0; - Internal?.GetStat( name, ref data ); - return data; + float dataFloat = 0; + if (Internal?.GetStat(name, ref dataFloat) is true) + { + return dataFloat; + } + return GetStatInt(name); } /// diff --git a/LinuxSolution.sln b/LinuxSolution.sln index 8c10904cd..80f20e9e5 100644 --- a/LinuxSolution.sln +++ b/LinuxSolution.sln @@ -41,7 +41,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MonoGame.Framework.Linux.Ne EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinuxTest", "Barotrauma\BarotraumaTest\LinuxTest.csproj", "{F1B80D94-8BD6-48CE-8D17-BB2A5C98BCA3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DeployAll", "Deploy\DeployAll\DeployAll.csproj", "{60B82E13-2CDD-4C74-8373-FD7264D6C80B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeployAll", "Deploy\DeployAll\DeployAll.csproj", "{60B82E13-2CDD-4C74-8373-FD7264D6C80B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BarotraumaLibs", "BarotraumaLibs", "{E63B6919-54B8-40BA-8FFF-FCCB9642358A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BarotraumaCore", "Libraries\BarotraumaLibs\BarotraumaCore\BarotraumaCore.csproj", "{A1686660-B920-407C-BBB9-C9C49543FBDD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface", "Libraries\BarotraumaLibs\EosInterface\EosInterface.csproj", "{AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface.Implementation.Linux", "Libraries\BarotraumaLibs\EosInterfacePrivate\EosInterface.Implementation.Linux.csproj", "{4C00CD9A-4241-43E4-BB62-9356324FE81C}" EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution @@ -212,6 +220,42 @@ Global {60B82E13-2CDD-4C74-8373-FD7264D6C80B}.Unstable|Any CPU.Build.0 = Debug|Any CPU {60B82E13-2CDD-4C74-8373-FD7264D6C80B}.Unstable|x64.ActiveCfg = Debug|Any CPU {60B82E13-2CDD-4C74-8373-FD7264D6C80B}.Unstable|x64.Build.0 = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Debug|x64.Build.0 = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Release|Any CPU.Build.0 = Release|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Release|x64.ActiveCfg = Release|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Release|x64.Build.0 = Release|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Unstable|x64.ActiveCfg = Debug|Any CPU + {A1686660-B920-407C-BBB9-C9C49543FBDD}.Unstable|x64.Build.0 = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Debug|x64.Build.0 = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Release|Any CPU.Build.0 = Release|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Release|x64.ActiveCfg = Release|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Release|x64.Build.0 = Release|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Unstable|x64.ActiveCfg = Debug|Any CPU + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA}.Unstable|x64.Build.0 = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Debug|x64.Build.0 = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Release|Any CPU.Build.0 = Release|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Release|x64.ActiveCfg = Release|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Release|x64.Build.0 = Release|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Unstable|x64.ActiveCfg = Debug|Any CPU + {4C00CD9A-4241-43E4-BB62-9356324FE81C}.Unstable|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -231,6 +275,10 @@ Global {33E95A21-E071-4432-819F-AA64CF3EF3F1} = {DE36F45F-F09E-4719-B953-00D148F7722A} {F1B80D94-8BD6-48CE-8D17-BB2A5C98BCA3} = {68B18BE6-9EE0-49DA-AE3A-4C7326F768F9} {60B82E13-2CDD-4C74-8373-FD7264D6C80B} = {F35DF9BF-0BED-4FEF-A51C-DD83C531882F} + {E63B6919-54B8-40BA-8FFF-FCCB9642358A} = {DE36F45F-F09E-4719-B953-00D148F7722A} + {A1686660-B920-407C-BBB9-C9C49543FBDD} = {E63B6919-54B8-40BA-8FFF-FCCB9642358A} + {AA83FAA6-12EF-4EC8-A10C-CF92C78706CA} = {E63B6919-54B8-40BA-8FFF-FCCB9642358A} + {4C00CD9A-4241-43E4-BB62-9356324FE81C} = {E63B6919-54B8-40BA-8FFF-FCCB9642358A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {17032EAB-554B-4B44-A4F6-EFB177ACAB7A} diff --git a/MacSolution.sln b/MacSolution.sln index ee5cddf3f..9d317fedb 100644 --- a/MacSolution.sln +++ b/MacSolution.sln @@ -43,6 +43,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MacTest", "Barotrauma\Barot EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DeployAll", "Deploy\DeployAll\DeployAll.csproj", "{36B38D18-3574-4B67-A89C-FD3C2D39F1D6}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BarotraumaLibs", "BarotraumaLibs", "{02FB212A-F4D5-4AE7-9158-B0E9CEF51200}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BarotraumaCore", "Libraries\BarotraumaLibs\BarotraumaCore\BarotraumaCore.csproj", "{53137227-66ED-48B5-A14F-BABD18D9C797}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface", "Libraries\BarotraumaLibs\EosInterface\EosInterface.csproj", "{AB7F7CD6-7985-4FA6-A398-B104498401E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface.Implementation.MacOS", "Libraries\BarotraumaLibs\EosInterfacePrivate\EosInterface.Implementation.MacOS.csproj", "{F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution Libraries\GameAnalytics\GA-SDK-MONO-SHARED\GA-SDK-MONO-SHARED.projitems*{c54f0dfe-add3-4767-8cbc-101859218d66}*SharedItemsImports = 5 @@ -212,6 +220,42 @@ Global {36B38D18-3574-4B67-A89C-FD3C2D39F1D6}.Unstable|Any CPU.Build.0 = Debug|Any CPU {36B38D18-3574-4B67-A89C-FD3C2D39F1D6}.Unstable|x64.ActiveCfg = Debug|Any CPU {36B38D18-3574-4B67-A89C-FD3C2D39F1D6}.Unstable|x64.Build.0 = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Debug|x64.ActiveCfg = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Debug|x64.Build.0 = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Release|Any CPU.Build.0 = Release|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Release|x64.ActiveCfg = Release|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Release|x64.Build.0 = Release|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Unstable|x64.ActiveCfg = Debug|Any CPU + {53137227-66ED-48B5-A14F-BABD18D9C797}.Unstable|x64.Build.0 = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Debug|x64.Build.0 = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Release|Any CPU.Build.0 = Release|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Release|x64.ActiveCfg = Release|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Release|x64.Build.0 = Release|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Unstable|x64.ActiveCfg = Debug|Any CPU + {AB7F7CD6-7985-4FA6-A398-B104498401E5}.Unstable|x64.Build.0 = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Debug|x64.Build.0 = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Release|Any CPU.Build.0 = Release|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Release|x64.ActiveCfg = Release|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Release|x64.Build.0 = Release|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Unstable|Any CPU.ActiveCfg = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Unstable|Any CPU.Build.0 = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Unstable|x64.ActiveCfg = Debug|Any CPU + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC}.Unstable|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -231,6 +275,10 @@ Global {F10CE3BB-26B8-446E-84D2-86D25E850F61} = {DE36F45F-F09E-4719-B953-00D148F7722A} {20BC9336-B439-4BF1-8B65-D587DBF421D1} = {DFD82BBD-8D05-403D-BEBC-F4C1CF783E18} {36B38D18-3574-4B67-A89C-FD3C2D39F1D6} = {F35DF9BF-0BED-4FEF-A51C-DD83C531882F} + {02FB212A-F4D5-4AE7-9158-B0E9CEF51200} = {DE36F45F-F09E-4719-B953-00D148F7722A} + {53137227-66ED-48B5-A14F-BABD18D9C797} = {02FB212A-F4D5-4AE7-9158-B0E9CEF51200} + {AB7F7CD6-7985-4FA6-A398-B104498401E5} = {02FB212A-F4D5-4AE7-9158-B0E9CEF51200} + {F0AD4BF3-D7CB-4FE7-8013-5715A0E6B2EC} = {02FB212A-F4D5-4AE7-9158-B0E9CEF51200} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {17032EAB-554B-4B44-A4F6-EFB177ACAB7A} diff --git a/WindowsSolution.sln b/WindowsSolution.sln index cff6db096..f38dff03a 100644 --- a/WindowsSolution.sln +++ b/WindowsSolution.sln @@ -43,6 +43,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsTest", "Barotrauma\B EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DeployAll", "Deploy\DeployAll\DeployAll.csproj", "{C98FE0D0-BC7D-4806-B592-734B53016FD8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BarotraumaLibs", "BarotraumaLibs", "{29DF600C-C2EB-48F5-9CCB-7E3B1409D95F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BarotraumaCore", "Libraries\BarotraumaLibs\BarotraumaCore\BarotraumaCore.csproj", "{FA273D62-455C-4BF7-B020-D0EBDE9EB565}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface", "Libraries\BarotraumaLibs\EosInterface\EosInterface.csproj", "{38C5D23D-0858-4254-B7B7-145221A8AB75}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EosInterface.Implementation.Win64", "Libraries\BarotraumaLibs\EosInterfacePrivate\EosInterface.Implementation.Win64.csproj", "{B411A619-1643-4C5F-A95D-9427D59BE010}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution Libraries\GameAnalytics\GA-SDK-MONO-SHARED\GA-SDK-MONO-SHARED.projitems*{95c4d59d-9be4-4278-b4f8-46c0ba1a3916}*SharedItemsImports = 5 @@ -123,20 +131,32 @@ Global {C7212AE2-A925-4225-A639-AE0653EF65B0}.Debug|x64.Build.0 = Debug|Any CPU {C7212AE2-A925-4225-A639-AE0653EF65B0}.Release|x64.ActiveCfg = Release|Any CPU {C7212AE2-A925-4225-A639-AE0653EF65B0}.Release|x64.Build.0 = Release|Any CPU - {C7212AE2-A925-4225-A639-AE0653EF65B0}.Unstable|x64.ActiveCfg = Debug|Any CPU - {C7212AE2-A925-4225-A639-AE0653EF65B0}.Unstable|x64.Build.0 = Debug|Any CPU + {C7212AE2-A925-4225-A639-AE0653EF65B0}.Unstable|x64.ActiveCfg = Release|Any CPU + {C7212AE2-A925-4225-A639-AE0653EF65B0}.Unstable|x64.Build.0 = Release|Any CPU {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Debug|x64.ActiveCfg = Debug|Any CPU {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Debug|x64.Build.0 = Debug|Any CPU {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Release|x64.ActiveCfg = Release|Any CPU {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Release|x64.Build.0 = Release|Any CPU - {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Unstable|x64.ActiveCfg = Debug|Any CPU - {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Unstable|x64.Build.0 = Debug|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Debug|x64.ActiveCfg = Debug|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Debug|x64.Build.0 = Debug|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Release|x64.ActiveCfg = Release|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Release|x64.Build.0 = Release|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Unstable|x64.ActiveCfg = Debug|Any CPU - {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6}.Unstable|x64.Build.0 = Debug|Any CPU + {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Unstable|x64.ActiveCfg = Release|Any CPU + {C98FE0D0-BC7D-4806-B592-734B53016FD8}.Unstable|x64.Build.0 = Release|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Debug|x64.Build.0 = Debug|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Release|x64.ActiveCfg = Release|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Release|x64.Build.0 = Release|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Unstable|x64.ActiveCfg = Release|Any CPU + {FA273D62-455C-4BF7-B020-D0EBDE9EB565}.Unstable|x64.Build.0 = Release|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Debug|x64.ActiveCfg = Debug|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Debug|x64.Build.0 = Debug|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Release|x64.ActiveCfg = Release|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Release|x64.Build.0 = Release|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Unstable|x64.ActiveCfg = Release|Any CPU + {38C5D23D-0858-4254-B7B7-145221A8AB75}.Unstable|x64.Build.0 = Release|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Debug|x64.ActiveCfg = Debug|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Debug|x64.Build.0 = Debug|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Release|x64.ActiveCfg = Release|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Release|x64.Build.0 = Release|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Unstable|x64.ActiveCfg = Release|Any CPU + {B411A619-1643-4C5F-A95D-9427D59BE010}.Unstable|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -156,6 +176,10 @@ Global {6911872D-40EF-400C-B0A1-9985A19ED488} = {DE36F45F-F09E-4719-B953-00D148F7722A} {C7212AE2-A925-4225-A639-AE0653EF65B0} = {78A9F0AA-5519-407A-9B72-2A09F5DF7068} {C98FE0D0-BC7D-4806-B592-734B53016FD8} = {F35DF9BF-0BED-4FEF-A51C-DD83C531882F} + {29DF600C-C2EB-48F5-9CCB-7E3B1409D95F} = {DE36F45F-F09E-4719-B953-00D148F7722A} + {FA273D62-455C-4BF7-B020-D0EBDE9EB565} = {29DF600C-C2EB-48F5-9CCB-7E3B1409D95F} + {38C5D23D-0858-4254-B7B7-145221A8AB75} = {29DF600C-C2EB-48F5-9CCB-7E3B1409D95F} + {B411A619-1643-4C5F-A95D-9427D59BE010} = {29DF600C-C2EB-48F5-9CCB-7E3B1409D95F} {D42D8E48-A687-445D-AAF1-9E7E6EBF1DE6} = {DE36F45F-F09E-4719-B953-00D148F7722A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution