diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index cbc74ed4f..a82f38bb1 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -82,6 +82,12 @@ public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader r if (reason == MpDisconnectReason.ServerPacketRead) descKey = "MpPacketErrorRemote"; if (reason == MpDisconnectReason.BadGamePassword) descKey = "MpBadGamePassword"; + if (reason == MpDisconnectReason.BootstrapCompleted) + { + titleKey = "MpBootstrapCompleted"; + descKey = "MpBootstrapCompletedDesc"; + } + disconnectInfo.titleTranslated ??= titleKey?.Translate(); disconnectInfo.descTranslated ??= descKey?.Translate(); diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 0aa262079..fbe44321c 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -7,7 +7,6 @@ using Verse; using Verse.Profile; using Verse.Sound; -using Verse.Steam; using Multiplayer.Client.Util; using Multiplayer.Common.Util; @@ -67,7 +66,6 @@ public HostWindow(SaveFile file = null) private const int MaxGameNameLength = 70; private const float LabelWidth = 110f; - private const float CheckboxWidth = LabelWidth + 30f; public override void DoWindowContents(Rect inRect) { @@ -134,268 +132,30 @@ private void DoTabButton(Rect r, Tab tab) private void DoConnecting(Rect entry) { - // Max players - MpUI.TextFieldNumericLabeled(entry.Width(LabelWidth + 35f), $"{"MpMaxPlayers".Translate()}: ", ref serverSettings.maxPlayers, ref maxPlayersBuffer, LabelWidth, 0, 999); - entry = entry.Down(30); - - // Password - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpHostGamePassword".Translate()}: ", ref serverSettings.hasPassword, order: ElementOrder.Right); - if (serverSettings.hasPassword) - MpUI.DoPasswordField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), "PasswordField", ref serverSettings.password); - entry = entry.Down(30); - - // Direct hosting - var directLabel = $"{"MpHostDirect".Translate()}: "; - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), directLabel, ref serverSettings.direct, order: ElementOrder.Right); - TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpHostDirectDesc", 4)); - if (serverSettings.direct) - serverSettings.directAddress = Widgets.TextField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), serverSettings.directAddress); - - entry = entry.Down(30); - - // LAN hosting - var lanRect = entry.Width(CheckboxWidth); - MpUI.CheckboxLabeled(lanRect, $"{"MpLan".Translate()}: ", ref serverSettings.lan, order: ElementOrder.Right); - TooltipHandler.TipRegion(lanRect, $"{"MpLanDesc1".Translate()}\n\n{"MpLanDesc2".Translate(serverSettings.lanAddress)}"); - - entry = entry.Down(30); - - // Steam hosting - if (SteamManager.Initialized) + var buffers = new ServerSettingsUI.BufferSet { - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSteam".Translate()}: ", ref serverSettings.steam, order: ElementOrder.Right); - entry = entry.Down(30); - } - - // Sync configs - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), MpUtil.TranslateWithDoubleNewLines("MpSyncConfigsDescNew", 3)); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref serverSettings.syncConfigs, order: ElementOrder.Right); - entry = entry.Down(30); - } - - private void DoGameplay(Rect entry) - { - // Autosave interval - var autosaveUnitKey = serverSettings.autosaveUnit == AutosaveUnit.Days - ? "MpAutosavesDays" - : "MpAutosavesMinutes"; - - bool changeAutosaveUnit = false; - - LeftLabel(entry, $"{"MpAutosaves".Translate()}: "); - TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpAutosavesDesc", 3)); - - using (MpStyle.Set(TextAnchor.MiddleRight)) - DoRow( - entry.Right(LabelWidth + 10), - rect => MpUI.LabelFlexibleWidth(rect, "MpAutosavesEvery".Translate()) + 6, - rect => - { - Widgets.TextFieldNumeric( - rect.Width(50f), - ref serverSettings.autosaveInterval, - ref autosaveBuffer, - 0, - 999 - ); - return 50f + 6; - }, - rect => - { - changeAutosaveUnit = CustomButton(rect, autosaveUnitKey.Translate(), out var width); - return width; - } - ); - - if (changeAutosaveUnit) - { - serverSettings.autosaveUnit = serverSettings.autosaveUnit.Cycle(); - serverSettings.autosaveInterval *= - serverSettings.autosaveUnit == AutosaveUnit.Minutes ? - 8f : // Days to minutes - 0.125f; // Minutes to days - autosaveBuffer = serverSettings.autosaveInterval.ToString(); - } - - entry = entry.Down(30); - - // Multifaction - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"Multifaction: ", ref serverSettings.multifaction, order: ElementOrder.Right, disabled: multifactionLocked); - entry = entry.Down(30); - - // Async time - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), $"{"MpAsyncTimeDesc".Translate()}\n\n{"MpExperimentalFeature".Translate()}"); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpAsyncTime".Translate()}: ", ref serverSettings.asyncTime, order: ElementOrder.Right, disabled: asyncTimeLocked); - entry = entry.Down(30); - - // Time control - LeftLabel(entry, $"{"MpTimeControl".Translate()}: "); - DoTimeControl(entry.Right(LabelWidth + 10)); - - entry = entry.Down(30); - - // Log desync traces - MpUI.CheckboxLabeledWithTipNoHighlight( - entry.Width(CheckboxWidth), - $"{"MpLogDesyncTraces".Translate()}: ", - MpUtil.TranslateWithDoubleNewLines("MpLogDesyncTracesDesc", 2), - ref serverSettings.desyncTraces, - placeTextNearCheckbox: true - ); - entry = entry.Down(30); - - // Arbiter - if (MpVersion.IsDebug) { - TooltipHandler.TipRegion(entry.Width(CheckboxWidth), "MpArbiterDesc".Translate()); - MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpRunArbiter".Translate()}: ", ref serverSettings.arbiter, order: ElementOrder.Right); - entry = entry.Down(30); - } - - // Dev mode - MpUI.CheckboxLabeledWithTipNoHighlight( - entry.Width(CheckboxWidth), - $"{"MpHostingDevMode".Translate()}: ", - MpUtil.TranslateWithDoubleNewLines("MpHostingDevModeDesc", 2), - ref serverSettings.debugMode, - placeTextNearCheckbox: true - ); - - // Dev mode scope - if (serverSettings.debugMode) - if (CustomButton(entry.Right(CheckboxWidth + 10f), $"MpHostingDevMode{serverSettings.devModeScope}".Translate())) - { - serverSettings.devModeScope = serverSettings.devModeScope.Cycle(); - SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); - } - - entry = entry.Down(30); - - // Auto join-points - DrawJoinPointOptions(entry); - entry = entry.Down(30); - - // Pause on letter - LeftLabel(entry, $"{"MpPauseOnLetter".Translate()}: "); - DoPauseOnLetter(entry.Right(LabelWidth + 10)); - entry = entry.Down(30); - - // Pause on (join, desync) - LeftLabel(entry, $"{"MpPauseOn".Translate()}: "); - DoRow( - entry.Right(LabelWidth + 10), - rect => MpUI.CheckboxLabeled( - rect.Width(CheckboxWidth), - "MpPauseOnJoin".Translate(), - ref serverSettings.pauseOnJoin, - size: 20f, - order: ElementOrder.Left).width + 15, - rect => MpUI.CheckboxLabeled( - rect.Width(CheckboxWidth), - "MpPauseOnDesync".Translate(), - ref serverSettings.pauseOnDesync, - size: 20f, - order: ElementOrder.Left).width - ); - - entry = entry.Down(30); - } - - private void DoTimeControl(Rect entry) - { - if (CustomButton(entry, $"MpTimeControl{serverSettings.timeControl}".Translate())) - Find.WindowStack.Add(new FloatMenu(Options().ToList())); - - IEnumerable Options() - { - foreach (var opt in Enum.GetValues(typeof(TimeControl)).OfType()) - yield return new FloatMenuOption($"MpTimeControl{opt}".Translate(), () => - { - serverSettings.timeControl = opt; - }); - } - } - - private void DoPauseOnLetter(Rect entry) - { - if (CustomButton(entry, $"MpPauseOnLetter{serverSettings.pauseOnLetter}".Translate())) - Find.WindowStack.Add(new FloatMenu(Options().ToList())); - - IEnumerable Options() - { - foreach (var opt in Enum.GetValues(typeof(PauseOnLetter)).OfType()) - yield return new FloatMenuOption($"MpPauseOnLetter{opt}".Translate(), () => - { - serverSettings.pauseOnLetter = opt; - }); - } - } + MaxPlayersBuffer = maxPlayersBuffer, + AutosaveBuffer = autosaveBuffer + }; - static float LeftLabel(Rect entry, string text, string desc = null) - { - using (MpStyle.Set(TextAnchor.MiddleRight)) - MpUI.LabelWithTip( - entry.Width(LabelWidth + 1), - text, - desc - ); - return Text.CalcSize(text).x; - } + ServerSettingsUI.DrawNetworkingSettings(entry, serverSettings, buffers); - static void DoRow(Rect inRect, params Func[] drawers) - { - foreach (var drawer in drawers) - { - inRect.xMin += drawer(inRect); - } + maxPlayersBuffer = buffers.MaxPlayersBuffer; + autosaveBuffer = buffers.AutosaveBuffer; } - private static Color CustomButtonColor = new(0.15f, 0.15f, 0.15f); - - private void DrawJoinPointOptions(Rect entry) + private void DoGameplay(Rect entry) { - LeftLabel(entry, $"{"MpAutoJoinPoints".Translate()}: ", MpUtil.TranslateWithDoubleNewLines("MpAutoJoinPointsDesc", 3)); - - var flags = Enum.GetValues(typeof(AutoJoinPointFlags)) - .OfType() - .Where(f => serverSettings.autoJoinPoint.HasFlag(f)) - .Select(f => $"MpAutoJoinPoints{f}".Translate()) - .Join(", "); - if (flags.Length == 0) flags = "Off"; - - if (CustomButton(entry.Right(LabelWidth + 10), flags)) - Find.WindowStack.Add(new FloatMenu(Flags().ToList())); - - IEnumerable Flags() + var buffers = new ServerSettingsUI.BufferSet { - foreach (var flag in Enum.GetValues(typeof(AutoJoinPointFlags)).OfType()) - yield return new FloatMenuOption($"MpAutoJoinPoints{flag}".Translate(), () => - { - if (serverSettings.autoJoinPoint.HasFlag(flag)) - serverSettings.autoJoinPoint &= ~flag; - else - serverSettings.autoJoinPoint |= flag; - }); - } - } - - private static bool CustomButton(Rect rect, string label) - => CustomButton(rect, label, out _); - - private static bool CustomButton(Rect rect, string label, out float width) - { - using var _ = MpStyle.Set(TextAnchor.MiddleLeft); - var flagsWidth = Text.CalcSize(label).x; - - const float btnMargin = 5f; - - var flagsBtn = rect.Width(flagsWidth + btnMargin * 2); - Widgets.DrawRectFast(flagsBtn.Height(24).Down(3), CustomButtonColor); - Widgets.DrawHighlightIfMouseover(flagsBtn.Height(24).Down(3)); - MpUI.Label(rect.Right(btnMargin).Width(flagsWidth), label); + MaxPlayersBuffer = maxPlayersBuffer, + AutosaveBuffer = autosaveBuffer + }; - width = flagsBtn.width; + ServerSettingsUI.DrawGameplaySettings(entry, serverSettings, buffers, asyncTimeLocked, multifactionLocked); - return Widgets.ButtonInvisible(flagsBtn); + maxPlayersBuffer = buffers.MaxPlayersBuffer; + autosaveBuffer = buffers.AutosaveBuffer; } private void TryHost() diff --git a/Source/Client/Windows/ServerSettingsUI.cs b/Source/Client/Windows/ServerSettingsUI.cs new file mode 100644 index 000000000..c9314fdd5 --- /dev/null +++ b/Source/Client/Windows/ServerSettingsUI.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using Multiplayer.Common.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; +using Verse.Steam; + +namespace Multiplayer.Client +{ + /// + /// Shared UI components for drawing ServerSettings fields. + /// Used by both HostWindow and BootstrapConfiguratorWindow. + /// + public static class ServerSettingsUI + { + private const float LabelWidth = 110f; + private const float CheckboxWidth = LabelWidth + 30f; + private static Color CustomButtonColor = new(0.15f, 0.15f, 0.15f); + + // Buffer references - caller must manage these + public class BufferSet + { + public string MaxPlayersBuffer; + public string AutosaveBuffer; + } + + /// + /// Draw networking-related settings (max players, password, direct/LAN/steam, sync configs). + /// + public static void DrawNetworkingSettings(Rect entry, ServerSettings settings, BufferSet buffers) + { + // Max players + MpUI.TextFieldNumericLabeled(entry.Width(LabelWidth + 35f), $"{"MpMaxPlayers".Translate()}: ", ref settings.maxPlayers, ref buffers.MaxPlayersBuffer, LabelWidth, 0, 999); + entry = entry.Down(30); + + // Password + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpHostGamePassword".Translate()}: ", ref settings.hasPassword, order: ElementOrder.Right); + if (settings.hasPassword) + MpUI.DoPasswordField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), "PasswordField", ref settings.password); + entry = entry.Down(30); + + // Direct hosting + var directLabel = $"{"MpHostDirect".Translate()}: "; + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), directLabel, ref settings.direct, order: ElementOrder.Right); + TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpHostDirectDesc", 4)); + if (settings.direct) + settings.directAddress = Widgets.TextField(entry.Right(CheckboxWidth + 10).MaxX(entry.xMax), settings.directAddress); + + entry = entry.Down(30); + + // LAN hosting + var lanRect = entry.Width(CheckboxWidth); + MpUI.CheckboxLabeled(lanRect, $"{"MpLan".Translate()}: ", ref settings.lan, order: ElementOrder.Right); + TooltipHandler.TipRegion(lanRect, $"{"MpLanDesc1".Translate()}\n\n{"MpLanDesc2".Translate(settings.lanAddress)}"); + + entry = entry.Down(30); + + // Steam hosting + var steamRect = entry.Width(CheckboxWidth); + if (!SteamManager.Initialized) settings.steam = false; + MpUI.CheckboxLabeled(steamRect, $"{"MpSteam".Translate()}: ", ref settings.steam, order: ElementOrder.Right, disabled: !SteamManager.Initialized); + if (!SteamManager.Initialized) TooltipHandler.TipRegion(steamRect, "MpSteamNotAvailable".Translate()); + entry = entry.Down(30); + + // Sync configs + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), MpUtil.TranslateWithDoubleNewLines("MpSyncConfigsDescNew", 3)); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref settings.syncConfigs, order: ElementOrder.Right); + } + + /// + /// Draw gameplay-related settings (autosave, multifaction, async time, time control, etc.). + /// + public static void DrawGameplaySettings(Rect entry, ServerSettings settings, BufferSet buffers, bool asyncTimeLocked = false, bool multifactionLocked = false) + { + // Autosave interval + var autosaveUnitKey = settings.autosaveUnit == AutosaveUnit.Days + ? "MpAutosavesDays" + : "MpAutosavesMinutes"; + + bool changeAutosaveUnit = false; + + LeftLabel(entry, $"{"MpAutosaves".Translate()}: "); + TooltipHandler.TipRegion(entry.Width(LabelWidth), MpUtil.TranslateWithDoubleNewLines("MpAutosavesDesc", 3)); + + using (MpStyle.Set(TextAnchor.MiddleRight)) + DoRow( + entry.Right(LabelWidth + 10), + rect => MpUI.LabelFlexibleWidth(rect, "MpAutosavesEvery".Translate()) + 6, + rect => + { + Widgets.TextFieldNumeric( + rect.Width(50f), + ref settings.autosaveInterval, + ref buffers.AutosaveBuffer, + 0, + 999 + ); + return 50f + 6; + }, + rect => + { + changeAutosaveUnit = CustomButton(rect, autosaveUnitKey.Translate(), out var width); + return width; + } + ); + + if (changeAutosaveUnit) + { + settings.autosaveUnit = settings.autosaveUnit.Cycle(); + settings.autosaveInterval *= + settings.autosaveUnit == AutosaveUnit.Minutes ? + 8f : // Days to minutes + 0.125f; // Minutes to days + buffers.AutosaveBuffer = settings.autosaveInterval.ToString(); + } + + entry = entry.Down(30); + + // Multifaction + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"Multifaction: ", ref settings.multifaction, order: ElementOrder.Right, disabled: multifactionLocked); + entry = entry.Down(30); + + // Async time + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), $"{"MpAsyncTimeDesc".Translate()}\n\n{"MpExperimentalFeature".Translate()}"); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpAsyncTime".Translate()}: ", ref settings.asyncTime, order: ElementOrder.Right, disabled: asyncTimeLocked); + entry = entry.Down(30); + + // Time control + LeftLabel(entry, $"{"MpTimeControl".Translate()}: "); + DoTimeControl(entry.Right(LabelWidth + 10), settings); + + entry = entry.Down(30); + + // Log desync traces + MpUI.CheckboxLabeledWithTipNoHighlight( + entry.Width(CheckboxWidth), + $"{"MpLogDesyncTraces".Translate()}: ", + MpUtil.TranslateWithDoubleNewLines("MpLogDesyncTracesDesc", 2), + ref settings.desyncTraces, + placeTextNearCheckbox: true + ); + entry = entry.Down(30); + + // Arbiter (debug only) + if (MpVersion.IsDebug) + { + TooltipHandler.TipRegion(entry.Width(CheckboxWidth), "MpArbiterDesc".Translate()); + MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpRunArbiter".Translate()}: ", ref settings.arbiter, order: ElementOrder.Right); + entry = entry.Down(30); + } + + // Dev mode + MpUI.CheckboxLabeledWithTipNoHighlight( + entry.Width(CheckboxWidth), + $"{"MpHostingDevMode".Translate()}: ", + MpUtil.TranslateWithDoubleNewLines("MpHostingDevModeDesc", 2), + ref settings.debugMode, + placeTextNearCheckbox: true + ); + + // Dev mode scope + if (settings.debugMode) + if (CustomButton(entry.Right(CheckboxWidth + 10f), $"MpHostingDevMode{settings.devModeScope}".Translate())) + { + settings.devModeScope = settings.devModeScope.Cycle(); + SoundDefOf.Checkbox_TurnedOn.PlayOneShotOnCamera(); + } + + entry = entry.Down(30); + + // Auto join-points + DrawJoinPointOptions(entry, settings); + entry = entry.Down(30); + + // Pause on letter + LeftLabel(entry, $"{"MpPauseOnLetter".Translate()}: "); + DoPauseOnLetter(entry.Right(LabelWidth + 10), settings); + entry = entry.Down(30); + + // Pause on (join, desync) + LeftLabel(entry, $"{"MpPauseOn".Translate()}: "); + DoRow( + entry.Right(LabelWidth + 10), + rect => MpUI.CheckboxLabeled( + rect.Width(CheckboxWidth), + "MpPauseOnJoin".Translate(), + ref settings.pauseOnJoin, + size: 20f, + order: ElementOrder.Left).width + 15, + rect => MpUI.CheckboxLabeled( + rect.Width(CheckboxWidth), + "MpPauseOnDesync".Translate(), + ref settings.pauseOnDesync, + size: 20f, + order: ElementOrder.Left).width + ); + } + + // Helper methods + + private static void DoTimeControl(Rect entry, ServerSettings settings) + { + if (CustomButton(entry, $"MpTimeControl{settings.timeControl}".Translate())) + Find.WindowStack.Add(new FloatMenu(Options(settings).ToList())); + + IEnumerable Options(ServerSettings s) + { + foreach (var opt in Enum.GetValues(typeof(TimeControl)).OfType()) + yield return new FloatMenuOption($"MpTimeControl{opt}".Translate(), () => + { + s.timeControl = opt; + }); + } + } + + private static void DoPauseOnLetter(Rect entry, ServerSettings settings) + { + if (CustomButton(entry, $"MpPauseOnLetter{settings.pauseOnLetter}".Translate())) + Find.WindowStack.Add(new FloatMenu(Options(settings).ToList())); + + IEnumerable Options(ServerSettings s) + { + foreach (var opt in Enum.GetValues(typeof(PauseOnLetter)).OfType()) + yield return new FloatMenuOption($"MpPauseOnLetter{opt}".Translate(), () => + { + s.pauseOnLetter = opt; + }); + } + } + + private static void DrawJoinPointOptions(Rect entry, ServerSettings settings) + { + LeftLabel(entry, $"{"MpAutoJoinPoints".Translate()}: ", MpUtil.TranslateWithDoubleNewLines("MpAutoJoinPointsDesc", 3)); + + var flags = Enum.GetValues(typeof(AutoJoinPointFlags)) + .OfType() + .Where(f => settings.autoJoinPoint.HasFlag(f)) + .Select(f => $"MpAutoJoinPoints{f}".Translate()) + .Join(", "); + if (flags.Length == 0) flags = "Off"; + + if (CustomButton(entry.Right(LabelWidth + 10), flags)) + Find.WindowStack.Add(new FloatMenu(Flags(settings).ToList())); + + IEnumerable Flags(ServerSettings s) + { + foreach (var flag in Enum.GetValues(typeof(AutoJoinPointFlags)).OfType()) + yield return new FloatMenuOption($"MpAutoJoinPoints{flag}".Translate(), () => + { + if (s.autoJoinPoint.HasFlag(flag)) + s.autoJoinPoint &= ~flag; + else + s.autoJoinPoint |= flag; + }); + } + } + + private static float LeftLabel(Rect entry, string text, string desc = null) + { + using (MpStyle.Set(TextAnchor.MiddleRight)) + MpUI.LabelWithTip( + entry.Width(LabelWidth + 1), + text, + desc + ); + return Text.CalcSize(text).x; + } + + private static void DoRow(Rect inRect, params Func[] drawers) + { + foreach (var drawer in drawers) + { + inRect.xMin += drawer(inRect); + } + } + + private static bool CustomButton(Rect rect, string label) + => CustomButton(rect, label, out _); + + private static bool CustomButton(Rect rect, string label, out float width) + { + using var _ = MpStyle.Set(TextAnchor.MiddleLeft); + var flagsWidth = Text.CalcSize(label).x; + + const float btnMargin = 5f; + + var flagsBtn = rect.Width(flagsWidth + btnMargin * 2); + Widgets.DrawRectFast(flagsBtn.Height(24).Down(3), CustomButtonColor); + Widgets.DrawHighlightIfMouseover(flagsBtn.Height(24).Down(3)); + MpUI.Label(rect.Right(btnMargin).Width(flagsWidth), label); + + width = flagsBtn.width; + + return Widgets.ButtonInvisible(flagsBtn); + } + } +} diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 061704c85..8217dbe0e 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -16,6 +16,7 @@ + diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 58ec8c663..324505dc8 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common { @@ -14,6 +15,7 @@ static MultiplayerServer() { MpConnectionState.SetImplementation(ConnectionStateEnum.ServerSteam, typeof(ServerSteamState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerJoining, typeof(ServerJoiningState)); + MpConnectionState.SetImplementation(ConnectionStateEnum.ServerBootstrap, typeof(ServerBootstrapState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerLoading, typeof(ServerLoadingState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ServerPlaying, typeof(ServerPlayingState)); } @@ -52,6 +54,12 @@ static MultiplayerServer() public volatile bool running; public event Action? TickEvent; + /// + /// True when the server is running without an initial save loaded. + /// In this mode the first connected client is expected to configure/upload the world. + /// + public bool BootstrapMode { get; set; } + public bool ArbiterPlaying => PlayingPlayers.Any(p => p.IsArbiter && p.status == PlayerStatus.Playing); public ServerPlayer HostPlayer => PlayingPlayers.First(p => p.IsHost); @@ -202,6 +210,12 @@ public void SendToPlaying(Packets id, object[] data) SendToPlaying(id, ByteWriter.GetBytes(data)); } + public void SendToPlaying(T packet, bool reliable = true, ServerPlayer? excluding = null) where T : IPacket + { + var materialized = packet.Serialize(); + SendToPlaying(materialized.id, materialized.data, reliable, excluding); + } + public void SendToPlaying(Packets id, byte[] data, bool reliable = true, ServerPlayer? excluding = null) { foreach (ServerPlayer player in PlayingPlayers) diff --git a/Source/Common/Networking/ConnectionStateEnum.cs b/Source/Common/Networking/ConnectionStateEnum.cs index 2dceb271a..262fe0dda 100644 --- a/Source/Common/Networking/ConnectionStateEnum.cs +++ b/Source/Common/Networking/ConnectionStateEnum.cs @@ -6,11 +6,13 @@ public enum ConnectionStateEnum : byte ClientLoading, ClientPlaying, ClientSteam, + ClientBootstrap, ServerJoining, ServerLoading, ServerPlaying, ServerSteam, // unused + ServerBootstrap, Count, Disconnected @@ -19,7 +21,7 @@ public enum ConnectionStateEnum : byte public static class ConnectionStateEnumExt { public static bool IsClient(this ConnectionStateEnum state) => - state is >= ConnectionStateEnum.ClientJoining and <= ConnectionStateEnum.ClientSteam; + state is >= ConnectionStateEnum.ClientJoining and <= ConnectionStateEnum.ClientBootstrap; public static bool IsServer(this ConnectionStateEnum state) => - state is >= ConnectionStateEnum.ServerJoining and <= ConnectionStateEnum.ServerSteam; + state is >= ConnectionStateEnum.ServerJoining and <= ConnectionStateEnum.ServerBootstrap; } diff --git a/Source/Common/Networking/MpDisconnectReason.cs b/Source/Common/Networking/MpDisconnectReason.cs index 7a1620532..571ae9b72 100644 --- a/Source/Common/Networking/MpDisconnectReason.cs +++ b/Source/Common/Networking/MpDisconnectReason.cs @@ -19,7 +19,8 @@ public enum MpDisconnectReason : byte Internal, ServerStarting, BadGamePassword, - StateException + StateException, + BootstrapCompleted } } diff --git a/Source/Common/Networking/Packet/BootstrapPacket.cs b/Source/Common/Networking/Packet/BootstrapPacket.cs new file mode 100644 index 000000000..7146ef830 --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapPacket.cs @@ -0,0 +1,19 @@ +namespace Multiplayer.Common.Networking.Packet; + +/// +/// Sent by the server during the initial connection handshake. +/// When enabled, the server is running in "bootstrap" mode (no save loaded yet) +/// and the client should enter the configuration flow instead of normal join. +/// +[PacketDefinition(Packets.Server_Bootstrap)] +public record struct ServerBootstrapPacket(bool bootstrap, bool settingsMissing = false) : IPacket +{ + public bool bootstrap = bootstrap; + public bool settingsMissing = settingsMissing; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref bootstrap); + buf.Bind(ref settingsMissing); + } +} diff --git a/Source/Common/Networking/Packet/BootstrapUploadPackets.cs b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs new file mode 100644 index 000000000..46fb23ad7 --- /dev/null +++ b/Source/Common/Networking/Packet/BootstrapUploadPackets.cs @@ -0,0 +1,97 @@ +using System; +using Multiplayer.Common; + +namespace Multiplayer.Common.Networking.Packet; + +/// +/// Uploads the full ServerSettings object for bootstrap configuration. +/// The server will persist it to settings.toml using the same ExposeData keys. +/// +[PacketDefinition(Packets.Client_BootstrapSettingsUploadStart)] +public record struct ClientBootstrapSettingsPacket(ServerSettings settings) : IPacket +{ + public ServerSettings settings = settings; + + public void Bind(PacketBuffer buf) + { + ServerSettingsPacketBinder.Bind(buf, ref settings); + } +} + +/// +/// Upload start metadata for bootstrap configuration. +/// The client will send exactly one file: a pre-built save.zip (server format). +/// +[PacketDefinition(Packets.Client_BootstrapUploadStart)] +public record struct ClientBootstrapSaveStartPacket(string fileName, int length) : IPacket +{ + public string fileName = fileName; + public int length = length; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref fileName); + buf.Bind(ref length); + } +} + +/// +/// Upload raw bytes for the save.zip. +/// This packet is expected to be delivered fragmented due to size. +/// +[PacketDefinition(Packets.Client_BootstrapUploadData, allowFragmented: true)] +public record struct ClientBootstrapSaveDataPacket(byte[] data) : IPacket +{ + public byte[] data = data; + + public void Bind(PacketBuffer buf) + { + buf.BindBytes(ref data, maxLength: -1); + } +} + +/// +/// Notify the server the upload has completed. +/// +[PacketDefinition(Packets.Client_BootstrapUploadFinish)] +public record struct ClientBootstrapSaveEndPacket(byte[] sha256Hash) : IPacket +{ + public byte[] sha256Hash = sha256Hash; + + public void Bind(PacketBuffer buf) + { + buf.BindBytes(ref sha256Hash, maxLength: 32); + } +} + +internal static class ServerSettingsPacketBinder +{ + public static void Bind(PacketBuffer buf, ref ServerSettings settings) + { + settings ??= new ServerSettings(); + + buf.Bind(ref settings.gameName, maxLength: 256); + // lanAddress is calculated server-side from lan setting, skip it + buf.Bind(ref settings.directAddress, maxLength: 256); + buf.Bind(ref settings.maxPlayers); + buf.Bind(ref settings.autosaveInterval); + buf.BindEnum(ref settings.autosaveUnit); + buf.Bind(ref settings.steam); + buf.Bind(ref settings.direct); + buf.Bind(ref settings.lan); + buf.Bind(ref settings.arbiter); + buf.Bind(ref settings.asyncTime); + buf.Bind(ref settings.multifaction); + buf.Bind(ref settings.debugMode); + buf.Bind(ref settings.desyncTraces); + buf.Bind(ref settings.syncConfigs); + buf.BindEnum(ref settings.autoJoinPoint); + buf.BindEnum(ref settings.devModeScope); + buf.Bind(ref settings.hasPassword); + buf.Bind(ref settings.password, maxLength: 256); + buf.BindEnum(ref settings.pauseOnLetter); + buf.Bind(ref settings.pauseOnJoin); + buf.Bind(ref settings.pauseOnDesync); + buf.BindEnum(ref settings.timeControl); + } +} diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 0b3f409e2..6d7748505 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -65,6 +65,15 @@ public enum Packets : byte // All states (Joining, Loading, Playing) Server_Disconnect, + // Bootstrap + Client_BootstrapSettingsUploadStart, + Client_BootstrapSettingsUploadData, + Client_BootstrapSettingsUploadFinish, + Client_BootstrapUploadStart, + Client_BootstrapUploadData, + Client_BootstrapUploadFinish, + Server_Bootstrap, + Count, Special_Steam_Disconnect = 63 // Also the max packet id } diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs new file mode 100644 index 000000000..ad5362705 --- /dev/null +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; +using System.Linq; +using Multiplayer.Common.Networking.Packet; +using Multiplayer.Common.Util; + +namespace Multiplayer.Common; + +/// +/// Server state used when the server is started in bootstrap mode (no save loaded). +/// It waits for a configuration client to upload a server-formatted save.zip. +/// Once received, the server writes it to disk and then disconnects all clients and stops, +/// so an external supervisor can restart it in normal mode. +/// +public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) +{ + // Only one configurator at a time; track by username to survive reconnections + private static string? configuratorUsername; + + // Save upload (save.zip) + private static string? pendingFileName; + private static int pendingLength; + private static byte[]? pendingZipBytes; + + public override void StartState() + { + // If we're not actually in bootstrap mode anymore, fall back. + if (!Server.BootstrapMode) + { + connection.ChangeState(ConnectionStateEnum.ServerJoining); + return; + } + + // If a different configurator is already active, keep this connection idle + if (configuratorUsername != null && configuratorUsername != connection.username) + { + // Still tell them we're in bootstrap, so clients can show a helpful UI. + var settingsMissing = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + connection.Send(new ServerBootstrapPacket(true, settingsMissing)); + return; + } + + // This is the configurator (either new or reconnecting) + configuratorUsername = connection.username; + var settingsMissing2 = !File.Exists(Path.Combine(AppContext.BaseDirectory, "settings.toml")); + connection.Send(new ServerBootstrapPacket(true, settingsMissing2)); + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + var savePath = Path.Combine(AppContext.BaseDirectory, "save.zip"); + + if (!File.Exists(settingsPath)) + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. Waiting for 'settings.toml' upload..."); + else if (!File.Exists(savePath)) + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. settings.toml already present; waiting for 'save.zip' upload..."); + else + ServerLog.Log($"Bootstrap: configurator '{connection.username}' connected. All files already present; waiting for shutdown."); + } + + public override void OnDisconnect() + { + if (configuratorUsername == connection.username) + { + ServerLog.Log("Bootstrap: configurator disconnected; returning to waiting state."); + ResetUploadState(); + } + } + + [TypedPacketHandler] + public void HandleSettings(ClientBootstrapSettingsPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (File.Exists(settingsPath)) + { + ServerLog.Log("Bootstrap: settings.toml already exists; ignoring settings upload."); + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + + var tempPath = settingsPath + ".tmp"; + TomlSettingsCommon.Save(packet.settings, tempPath); + + if (File.Exists(settingsPath)) + File.Delete(settingsPath); + + File.Move(tempPath, settingsPath); + + ServerLog.Log($"Bootstrap: wrote '{settingsPath}'. Waiting for save.zip upload..."); + } + + [TypedPacketHandler] + public void HandleSaveStart(ClientBootstrapSaveStartPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (!File.Exists(settingsPath)) + throw new PacketReadException("Bootstrap requires settings.toml before save.zip"); + + if (packet.length <= 0) + throw new PacketReadException("Bootstrap upload has invalid length"); + + pendingFileName = packet.fileName; + pendingLength = packet.length; + pendingZipBytes = null; + + ServerLog.Log($"Bootstrap: upload start '{pendingFileName}' ({pendingLength} bytes)"); + } + + [TypedPacketHandler] + public void HandleSaveData(ClientBootstrapSaveDataPacket packet) + { + if (!IsConfigurator()) + return; + + // Accumulate fragmented upload data + if (pendingZipBytes == null) + { + pendingZipBytes = packet.data; + } + else + { + // Append new chunk to existing data + var oldLen = pendingZipBytes.Length; + var newChunk = packet.data; + var combined = new byte[oldLen + newChunk.Length]; + Buffer.BlockCopy(pendingZipBytes, 0, combined, 0, oldLen); + Buffer.BlockCopy(newChunk, 0, combined, oldLen, newChunk.Length); + pendingZipBytes = combined; + } + + ServerLog.Log($"Bootstrap: upload data received ({packet.data?.Length ?? 0} bytes, total: {pendingZipBytes?.Length ?? 0}/{pendingLength})"); + } + + [TypedPacketHandler] + public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) + { + if (!IsConfigurator()) + return; + + var settingsPath = Path.Combine(AppContext.BaseDirectory, "settings.toml"); + if (!File.Exists(settingsPath)) + throw new PacketReadException("Bootstrap requires settings.toml before save.zip"); + + if (pendingZipBytes == null) + throw new PacketReadException("Bootstrap upload finish without data"); + + if (pendingLength > 0 && pendingZipBytes.Length != pendingLength) + ServerLog.Log($"Bootstrap: warning - expected {pendingLength} bytes but got {pendingZipBytes.Length}"); + + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var actualHash = sha256.ComputeHash(pendingZipBytes); + if (packet.sha256Hash != null && packet.sha256Hash.Length > 0 && !actualHash.SequenceEqual(packet.sha256Hash)) + { + throw new PacketReadException($"Bootstrap upload hash mismatch. expected={packet.sha256Hash.ToHexString()} actual={actualHash.ToHexString()}"); + } + + // Persist save.zip + var targetPath = Path.Combine(AppContext.BaseDirectory, "save.zip"); + var tempPath = targetPath + ".tmp"; + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllBytes(tempPath, pendingZipBytes); + if (File.Exists(targetPath)) + File.Delete(targetPath); + File.Move(tempPath, targetPath); + + ServerLog.Log($"Bootstrap: wrote '{targetPath}'. Configuration complete; disconnecting clients and stopping."); + + // Notify and disconnect all clients. + Server.SendToPlaying(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted, data = Array.Empty() }); + + // Stop the server loop; an external supervisor should restart. + Server.running = false; + Server.TryStop(); + } + + private bool IsConfigurator() => configuratorUsername == connection.username; + + private static void ResetUploadState() + { + pendingFileName = null; + pendingLength = 0; + pendingZipBytes = null; + + configuratorUsername = null; + } +} diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index fb09b0729..ded55a57e 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.IO; +using System.Threading.Tasks; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common; @@ -22,6 +24,14 @@ protected override async Task RunState() if (HandleClientJoinData(await Packet(Packets.Client_JoinData).Fragmented()) is false) return; + // In bootstrap mode we only need the handshake (protocol/username/join data) so the client can stay connected + // and upload settings/save. We must NOT proceed into world loading / playing states. + if (Server.BootstrapMode) + { + connection.ChangeState(ConnectionStateEnum.ServerBootstrap); + return; + } + if (Server.settings.pauseOnJoin) Server.commands.PauseAll(); @@ -43,7 +53,17 @@ private void HandleProtocol(ByteReader data) if (clientProtocol != MpVersion.Protocol) Player.Disconnect(MpDisconnectReason.Protocol, ByteWriter.GetBytes(MpVersion.Version, MpVersion.Protocol)); else + { Player.SendPacket(Packets.Server_ProtocolOk, new object[] { Server.settings.hasPassword }); + + // Let the client know early when the server is in bootstrap mode so it can switch + // to server-configuration flow while keeping the connection open. + var settingsMissing = false; + if (Server.BootstrapMode) + settingsMissing = !File.Exists(Path.Combine(System.AppContext.BaseDirectory, "settings.toml")); + + Player.conn.Send(new ServerBootstrapPacket(Server.BootstrapMode, settingsMissing)); + } } private void HandleUsername(ByteReader data) diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index 8bc6d310f..a0a716eb2 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -40,8 +40,8 @@ public void ExposeData() ScribeLike.Look(ref steam, "steam"); ScribeLike.Look(ref direct, "direct"); ScribeLike.Look(ref lan, "lan", true); - ScribeLike.Look(ref debugMode, "asyncTime"); - ScribeLike.Look(ref debugMode, "multifaction"); + ScribeLike.Look(ref asyncTime, "asyncTime"); + ScribeLike.Look(ref multifaction, "multifaction"); ScribeLike.Look(ref debugMode, "debugMode"); ScribeLike.Look(ref desyncTraces, "desyncTraces", true); ScribeLike.Look(ref syncConfigs, "syncConfigs", true); diff --git a/Source/Common/Util/TomlSettingsCommon.cs b/Source/Common/Util/TomlSettingsCommon.cs index 2dc23b638..b7a03911e 100644 --- a/Source/Common/Util/TomlSettingsCommon.cs +++ b/Source/Common/Util/TomlSettingsCommon.cs @@ -13,6 +13,16 @@ namespace Multiplayer.Common.Util /// public static class TomlSettingsCommon { + public static string Serialize(ServerSettings settings) + { + var scribe = new SimpleTomlScribe { mode = SimpleTomlMode.Saving }; + ScribeLike.provider = scribe; + + settings.ExposeData(); + + return scribe.ToToml(); + } + public static ServerSettings Load(string filename) { var scribe = new SimpleTomlScribe(); @@ -29,12 +39,7 @@ public static ServerSettings Load(string filename) public static void Save(ServerSettings settings, string filename) { - var scribe = new SimpleTomlScribe { mode = SimpleTomlMode.Saving }; - ScribeLike.provider = scribe; - - settings.ExposeData(); - - File.WriteAllText(filename, scribe.ToToml()); + File.WriteAllText(filename, Serialize(settings)); } } diff --git a/Source/Server/BootstrapMode.cs b/Source/Server/BootstrapMode.cs new file mode 100644 index 000000000..b47557f23 --- /dev/null +++ b/Source/Server/BootstrapMode.cs @@ -0,0 +1,27 @@ +using System.Threading; +using Multiplayer.Common; + +namespace Server; + +/// +/// Helpers for running the server in bootstrap mode (no save loaded yet). +/// +public static class BootstrapMode +{ + /// + /// Keeps the process alive while the server is waiting for a client to provide the initial world data. + /// + /// This is intentionally minimal for now: it just sleeps and checks the stop flag. + /// The networking + actual upload handling happens in the server thread/state machine. + /// + public static void WaitForClient(MultiplayerServer server, CancellationToken token) + { + ServerLog.Log("Bootstrap: waiting for first client connection..."); + + // Keep the process alive. The server's net loop runs on its own thread. + while (server.running && !token.IsCancellationRequested) + { + Thread.Sleep(250); + } + } +} diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 11dbaca0f..90553d43a 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -26,7 +26,7 @@ if (settings.lan) settings.lanAddress = GetLocalIpAddress() ?? "127.0.0.1"; } else - TomlSettingsCommon.Save(settings, settingsFile); // Save default settings + ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it."); var server = MultiplayerServer.instance = new MultiplayerServer(settings) { @@ -34,13 +34,39 @@ initDataState = InitDataState.Waiting }; +var bootstrap = !File.Exists(settingsFile); + var consoleSource = new ConsoleSource(); -LoadSave(server, saveFile); +if (!bootstrap && File.Exists(saveFile)) +{ + LoadSave(server, saveFile); +} +else +{ + bootstrap = true; + ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); + ServerLog.Log("Waiting for a client to upload world data."); +} +server.BootstrapMode = bootstrap; + +if (bootstrap) + ServerLog.Detail("Bootstrap flag is enabled."); + server.liteNet.StartNet(); new Thread(server.Run) { Name = "Server thread" }.Start(); +// In bootstrap mode we keep the server alive and wait for any client to connect. +// The actual world data upload is handled by the normal networking code paths. +if (bootstrap) + BootstrapMode.WaitForClient(server, CancellationToken.None); + +// If bootstrap mode completed (a client uploaded save.zip) the server thread will have set +// server.running = false. In that case, exit so the user can restart the server normally. +if (bootstrap && !server.running) + return; + while (true) { var cmd = Console.ReadLine();