From 91174f0ec154f1375714e0767fa670ddaecfcff1 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 25 Mar 2026 06:40:28 +0100 Subject: [PATCH 1/4] PR 6: Client bootstrap configurator Add client-side bootstrap flow for configuring and uploading server settings and save files to a headless server. New files: - ClientBootstrapState: connection state during bootstrap config - BootstrapConfiguratorWindow: full UI for settings upload, vanilla new colony flow, save generation, reconnection, and save upload - BootstrapCoordinator: GameComponent for reliable tick-based detection - 4 Harmony patches: BootstrapMapInitPatch, BootstrapRootPlayPatch, BootstrapRootPlayUpdatePatch, BootstrapStartedNewGamePatch Modified files: - ClientJoiningState: HandleBootstrap handler + bootstrap redirect - MultiplayerSession: serverIsInBootstrap, serverBootstrapSettingsMissing - Autosaving: atomic file replace, null session check for bootstrap - HostWindow: HostProgrammatically static method for bootstrap hosting - ServerSettingsUI: DrawGameplaySettingsOnly wrapper --- Source/Client/Comp/BootstrapCoordinator.cs | 42 + .../Networking/State/ClientBaseState.cs | 1 + .../Networking/State/ClientBootstrapState.cs | 38 + .../Networking/State/ClientJoiningState.cs | 16 + .../Networking/State/ClientPlayingState.cs | 1 + .../Networking/State/ClientSteamState.cs | 1 + .../Client/Patches/BootstrapMapInitPatch.cs | 21 + .../Client/Patches/BootstrapRootPlayPatch.cs | 22 + .../Patches/BootstrapRootPlayUpdatePatch.cs | 29 + .../Patches/BootstrapStartedNewGamePatch.cs | 28 + Source/Client/Session/Autosaving.cs | 16 +- Source/Client/Session/MultiplayerSession.cs | 4 + .../Windows/BootstrapConfiguratorWindow.cs | 961 ++++++++++++++++++ Source/Client/Windows/HostWindow.cs | 12 + Source/Client/Windows/ServerSettingsUI.cs | 6 + .../Networking/State/ServerBootstrapState.cs | 1 + .../Networking/State/ServerJoiningState.cs | 1 + .../Networking/State/ServerLoadingState.cs | 1 + .../Networking/State/ServerPlayingState.cs | 1 + .../Networking/State/ServerSteamState.cs | 1 + 20 files changed, 1200 insertions(+), 3 deletions(-) create mode 100644 Source/Client/Comp/BootstrapCoordinator.cs create mode 100644 Source/Client/Networking/State/ClientBootstrapState.cs create mode 100644 Source/Client/Patches/BootstrapMapInitPatch.cs create mode 100644 Source/Client/Patches/BootstrapRootPlayPatch.cs create mode 100644 Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs create mode 100644 Source/Client/Patches/BootstrapStartedNewGamePatch.cs create mode 100644 Source/Client/Windows/BootstrapConfiguratorWindow.cs diff --git a/Source/Client/Comp/BootstrapCoordinator.cs b/Source/Client/Comp/BootstrapCoordinator.cs new file mode 100644 index 000000000..d8b161fc9 --- /dev/null +++ b/Source/Client/Comp/BootstrapCoordinator.cs @@ -0,0 +1,42 @@ +using Verse; + +namespace Multiplayer.Client.Comp +{ + /// + /// Runs during bootstrap to detect when the new game has fully entered Playing and a map exists. + /// Keeps the save trigger logic reliable even when the bootstrap window may not receive regular updates. + /// + public class BootstrapCoordinator : GameComponent + { + private int nextCheckTick; + private const int CheckIntervalTicks = 60; // ~1s + + public BootstrapCoordinator(Game game) + { + } + + public override void GameComponentTick() + { + base.GameComponentTick(); + + var win = BootstrapConfiguratorWindow.Instance; + if (win == null) + return; + + // Throttle checks + if (Find.TickManager != null && Find.TickManager.TicksGame < nextCheckTick) + return; + + if (Find.TickManager != null) + nextCheckTick = Find.TickManager.TicksGame + CheckIntervalTicks; + + win.BootstrapCoordinatorTick(); + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref nextCheckTick, "mp_bootstrap_nextCheckTick", 0); + } + } +} diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 216f588b7..66b22f680 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -4,6 +4,7 @@ namespace Multiplayer.Client; +[PacketHandlerClass] public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionState(connection) { protected MultiplayerSession Session => Multiplayer.session; diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs new file mode 100644 index 000000000..a20fe152c --- /dev/null +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -0,0 +1,38 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using Verse; + +namespace Multiplayer.Client; + +/// +/// Client connection state used while configuring a bootstrap server. +/// The server is in ServerBootstrap and expects upload packets; the client must keep the connection alive +/// and handle bootstrap completion / disconnect packets. +/// +[PacketHandlerClass(inheritHandlers: true)] +public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) +{ + [TypedPacketHandler] + public new void HandleDisconnected(ServerDisconnectPacket packet) + { + // If bootstrap completed successfully, show success message before closing the window + if (packet.reason == MpDisconnectReason.BootstrapCompleted) + { + OnMainThread.Enqueue(() => Messages.Message( + "Bootstrap configuration completed. The server will now shut down; please restart it manually to start normally.", + MessageTypeDefOf.PositiveEvent, false)); + } + + // Close the bootstrap configurator window now that the process is complete + OnMainThread.Enqueue(() => + { + var window = Find.WindowStack.WindowOfType(); + if (window != null) + Find.WindowStack.TryRemove(window); + }); + + // Let the base class handle the disconnect + base.HandleDisconnected(packet); + } +} diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 17782468e..24f03c88f 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -2,18 +2,27 @@ using HarmonyLib; using Multiplayer.Client.Networking; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using System.Linq; using RimWorld; using Verse; namespace Multiplayer.Client { + [PacketHandlerClass] public class ClientJoiningState : ClientBaseState { public ClientJoiningState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleBootstrap(ServerBootstrapPacket packet) + { + Multiplayer.session.serverIsInBootstrap = packet.bootstrap; + Multiplayer.session.serverBootstrapSettingsMissing = packet.settingsMissing; + } + public override void StartState() { connection.Send(Packets.Client_Protocol, MpVersion.Protocol); @@ -132,6 +141,13 @@ void Complete() void StartDownloading() { + if (Multiplayer.session.serverIsInBootstrap) + { + connection.ChangeState(ConnectionStateEnum.ClientBootstrap); + Find.WindowStack.Add(new BootstrapConfiguratorWindow(connection)); + return; + } + connection.Send(Packets.Client_WorldRequest); connection.ChangeState(ConnectionStateEnum.ClientLoading); } diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 030569d1a..2593f3ac3 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -9,6 +9,7 @@ namespace Multiplayer.Client { + [PacketHandlerClass] public class ClientPlayingState : ClientBaseState { public ClientPlayingState(ConnectionBase connection) : base(connection) diff --git a/Source/Client/Networking/State/ClientSteamState.cs b/Source/Client/Networking/State/ClientSteamState.cs index f6896375c..f7ba15ee4 100644 --- a/Source/Client/Networking/State/ClientSteamState.cs +++ b/Source/Client/Networking/State/ClientSteamState.cs @@ -3,6 +3,7 @@ namespace Multiplayer.Client { + [PacketHandlerClass] public class ClientSteamState : MpConnectionState { public ClientSteamState(ConnectionBase connection) : base(connection) diff --git a/Source/Client/Patches/BootstrapMapInitPatch.cs b/Source/Client/Patches/BootstrapMapInitPatch.cs new file mode 100644 index 000000000..6052ef4a0 --- /dev/null +++ b/Source/Client/Patches/BootstrapMapInitPatch.cs @@ -0,0 +1,21 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + [HarmonyPatch(typeof(MapComponentUtility), nameof(MapComponentUtility.FinalizeInit))] + static class BootstrapMapInitPatch + { + static void Postfix(Map map) + { + if (BootstrapConfiguratorWindow.AwaitingBootstrapMapInit && + BootstrapConfiguratorWindow.Instance != null) + { + OnMainThread.Enqueue(() => + { + BootstrapConfiguratorWindow.Instance.OnBootstrapMapInitialized(); + }); + } + } + } +} diff --git a/Source/Client/Patches/BootstrapRootPlayPatch.cs b/Source/Client/Patches/BootstrapRootPlayPatch.cs new file mode 100644 index 000000000..40bb0b010 --- /dev/null +++ b/Source/Client/Patches/BootstrapRootPlayPatch.cs @@ -0,0 +1,22 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Root_Play.Start is called when the game fully transitions into Playing. + /// This is a reliable signal for arming the bootstrap map init detection. + /// + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Start))] + static class BootstrapRootPlayPatch + { + static void Postfix() + { + var inst = BootstrapConfiguratorWindow.Instance; + if (inst == null) + return; + + inst.TryArmAwaitingBootstrapMapInit_FromRootPlay(); + } + } +} diff --git a/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs b/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs new file mode 100644 index 000000000..54e591947 --- /dev/null +++ b/Source/Client/Patches/BootstrapRootPlayUpdatePatch.cs @@ -0,0 +1,29 @@ +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// Root_Play.Update runs through the whole transition from MapInitializing to Playing. + /// Arms the map-init trigger as soon as a map exists and ProgramState is Playing. + /// + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Update))] + static class BootstrapRootPlayUpdatePatch + { + private static int nextCheckFrame; + private const int CheckEveryFrames = 10; + + static void Postfix() + { + var win = BootstrapConfiguratorWindow.Instance; + if (win == null) + return; + + if (UnityEngine.Time.frameCount < nextCheckFrame) + return; + nextCheckFrame = UnityEngine.Time.frameCount + CheckEveryFrames; + + win.TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate(); + } + } +} diff --git a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs new file mode 100644 index 000000000..d55ca3717 --- /dev/null +++ b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs @@ -0,0 +1,28 @@ +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client +{ + /// + /// When the vanilla flow finishes generating the new game, this fires once the map and pawns are ready. + /// Backup signal to kick the bootstrap save pipeline in case FinalizeInit was missed or delayed. + /// + [HarmonyPatch(typeof(GameComponentUtility), nameof(GameComponentUtility.StartedNewGame))] + public static class BootstrapStartedNewGamePatch + { + static void Postfix() + { + var window = BootstrapConfiguratorWindow.Instance; + if (window == null) + return; + + BootstrapConfiguratorWindow.AwaitingBootstrapMapInit = true; + + OnMainThread.Enqueue(() => + { + window.OnBootstrapMapInitialized(); + }); + } + } +} diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 499303b05..a3f1a767c 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -39,14 +39,24 @@ public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur try { - new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip")).Delete(); - Replay.ForSaving(fileNameNoExtension).WriteData( + // Ensure the replays directory exists even when not connected to a server + Directory.CreateDirectory(Multiplayer.ReplaysDir); + + var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip")); + Replay.ForSaving(tmp).WriteData( currentReplay ? Multiplayer.session.dataSnapshot : SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) ); + + var dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip")); + if (!dst.Exists) dst.Open(FileMode.Create).Close(); + tmp.Replace(dst.FullName, null); + Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false); - Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; + // In bootstrap/offline mode there may be no active session + if (Multiplayer.session != null) + Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; } catch (Exception e) { diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 02d7a5e5f..651bf6888 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -61,6 +61,10 @@ public class MultiplayerSession : IConnectionStatusListener public int port; public CSteamID? steamHost; + // Set during handshake (see Server_Bootstrap packet) to indicate the server is waiting for configuration/upload. + public bool serverIsInBootstrap; + public bool serverBootstrapSettingsMissing; + public void Stop() { if (client != null) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs new file mode 100644 index 000000000..b792ee6a4 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -0,0 +1,961 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Security.Cryptography; +using LiteNetLib; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Networking; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; +using Multiplayer.Common.Util; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client +{ + /// + /// Shown when connecting to a server that's in bootstrap/configuration mode. + /// Guides the user through uploading settings.toml (if needed) and then save.zip. + /// + public class BootstrapConfiguratorWindow : Window + { + private readonly ConnectionBase connection; + private string serverAddress; + private int serverPort; + private bool isReconnecting; + private int reconnectCheckTimer; + + private ServerSettings settings; + + private enum Step + { + Settings, + GenerateMap + } + + private enum Tab + { + Connecting, + Gameplay, + Preview + } + + private Step step; + private Tab tab; + + private Vector2 scroll; + + // UI buffers + private ServerSettingsUI.BufferSet settingsUiBuffers = new(); + + // toml preview + private string tomlPreview; + private Vector2 tomlScroll; + + private bool isUploadingToml; + private float uploadProgress; + private string statusText; + private bool settingsUploaded; + + // Save.zip upload + private bool isUploadingSave; + private float saveUploadProgress; + private string saveUploadStatus; + private static string lastSavedReplayPath; + private static bool lastSaveReady; + + // Autosave trigger (once) during bootstrap map generation + private bool saveReady; + private string savedReplayPath; + + private const string BootstrapSaveName = "Bootstrap"; + private bool saveUploadAutoStarted; + private bool autoUploadAttempted; + + // Vanilla page auto-advance during bootstrap + private bool autoAdvanceArmed; + private float nextPressCooldown; + private float randomTileCooldown; + private const float NextPressCooldownSeconds = 0.45f; + private const float RandomTileCooldownSeconds = 0.9f; + private const float AutoAdvanceTimeoutSeconds = 180f; + private float autoAdvanceElapsed; + private bool worldGenDetected; + private float worldGenDelayRemaining; + private const float WorldGenDelaySeconds = 1f; + + private float autoAdvanceDiagCooldown; + private const float AutoAdvanceDiagCooldownSeconds = 2.0f; + + // Delay before saving after entering the map + private float postMapEnterSaveDelayRemaining; + private const float PostMapEnterSaveDelaySeconds = 1f; + + // Ensure we don't queue multiple saves. + private bool bootstrapSaveQueued; + + // After entering a map, wait until at least one controllable colonist pawn exists. + private bool awaitingControllablePawns; + private float awaitingControllablePawnsElapsed; + private const float AwaitControllablePawnsTimeoutSeconds = 30f; + private bool startingLettersCleared; + private bool landingDialogsCleared; + + // Static flag to track bootstrap map initialization + public static bool AwaitingBootstrapMapInit; + public static BootstrapConfiguratorWindow Instance; + + // Hide window during map generation/tile selection + private bool hideWindowDuringMapGen; + + private const float LabelWidth = 110f; + private const int MaxGameNameLength = 70; + + public override Vector2 InitialSize => new(550f, 620f); + + public BootstrapConfiguratorWindow(ConnectionBase connection) + { + this.connection = connection; + Instance = this; + + // Save server address for reconnection after world generation + serverAddress = Multiplayer.session?.address; + serverPort = Multiplayer.session?.port ?? 0; + + doCloseX = true; + closeOnClickedOutside = false; + absorbInputAroundWindow = false; + forcePause = false; + + // Initialize with reasonable defaults for standalone/headless server + settings = new ServerSettings + { + gameName = $"{Multiplayer.username}'s Server", + direct = true, + lan = false, + steam = false, + arbiter = false, + maxPlayers = 8, + autosaveInterval = 1, + autosaveUnit = AutosaveUnit.Days + }; + + // Initialize UI buffers + settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString(); + settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString(); + + // Choose the initial step based on what the server told us. + step = Multiplayer.session?.serverBootstrapSettingsMissing == true ? Step.Settings : Step.GenerateMap; + + statusText = step == Step.Settings + ? "Server settings.toml is missing. Configure and upload it." + : "Server settings.toml is already configured."; + + // Check if we have a previously saved Bootstrap.zip from this session (reconnect case) + if (!autoUploadAttempted && lastSaveReady && !string.IsNullOrEmpty(lastSavedReplayPath) && File.Exists(lastSavedReplayPath)) + { + Log.Message($"[Bootstrap] Found previous Bootstrap.zip at {lastSavedReplayPath}, auto-uploading..."); + savedReplayPath = lastSavedReplayPath; + saveReady = true; + saveUploadStatus = "Save ready from previous session. Uploading..."; + saveUploadAutoStarted = true; + autoUploadAttempted = true; + StartUploadSaveZip(); + } + + RebuildTomlPreview(); + } + + public override void DoWindowContents(Rect inRect) + { + Text.Font = GameFont.Medium; + Text.Anchor = TextAnchor.UpperCenter; + + // Title + Widgets.Label(inRect.Down(0), "Server Bootstrap Configuration"); + Text.Anchor = TextAnchor.UpperLeft; + Text.Font = GameFont.Small; + + var entry = new Rect(0, 45, inRect.width, 30f); + entry.xMin += 4; + + // Game name + settings.gameName = MpUI.TextEntryLabeled(entry, $"{"MpGameName".Translate()}: ", settings.gameName, LabelWidth); + if (settings.gameName.Length > MaxGameNameLength) + settings.gameName = settings.gameName.Substring(0, MaxGameNameLength); + + entry = entry.Down(40); + + if (step == Step.Settings) + DrawSettings(entry, inRect); + else + DrawGenerateMap(entry, inRect); + } + + private void DrawGenerateMap(Rect entry, Rect inRect) + { + // Status text + Text.Font = GameFont.Small; + var statusHeight = Text.CalcHeight(statusText ?? "", entry.width); + Widgets.Label(entry.Height(statusHeight), statusText ?? ""); + entry = entry.Down(statusHeight + 10); + + // Important notice about faction ownership + if (!AwaitingBootstrapMapInit && !saveReady && !isUploadingSave && !isReconnecting) + { + var noticeRect = entry.Height(100f); + GUI.color = new Color(1f, 0.85f, 0.5f); + Widgets.DrawBoxSolid(noticeRect, new Color(0.3f, 0.25f, 0.1f, 0.5f)); + GUI.color = Color.white; + + var noticeTextRect = noticeRect.ContractedBy(8f); + Text.Font = GameFont.Tiny; + GUI.color = new Color(1f, 0.9f, 0.6f); + Widgets.Label(noticeTextRect, + "IMPORTANT: The user who generates this map will own the main faction (colony).\n" + + "When setting up the server, make sure this user's username is listed as the host.\n" + + "Other players connecting to the server will be assigned as spectators or secondary factions."); + GUI.color = Color.white; + Text.Font = GameFont.Small; + entry = entry.Down(110); + } + + // Save upload status + if (!string.IsNullOrEmpty(saveUploadStatus)) + { + var saveStatusHeight = Text.CalcHeight(saveUploadStatus, entry.width); + Widgets.Label(entry.Height(saveStatusHeight), saveUploadStatus); + entry = entry.Down(saveStatusHeight + 4); + } + + // Progress bar + if (autoAdvanceArmed || isUploadingSave) + { + var barRect = entry.Height(18f); + Widgets.FillableBar(barRect, isUploadingSave ? saveUploadProgress : 0.1f); + entry = entry.Down(24); + } + + // Generate map button + bool showGenerateButton = !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); + if (showGenerateButton) + { + var buttonRect = new Rect((inRect.width - 200f) / 2f, inRect.height - 45f, 200f, 40f); + if (Widgets.ButtonText(buttonRect, "Generate map")) + { + saveUploadAutoStarted = false; + hideWindowDuringMapGen = true; + StartVanillaNewColonyFlow(); + } + } + + // Auto-start upload when save is ready + if (saveReady && !isUploadingSave && !saveUploadAutoStarted) + { + saveUploadAutoStarted = true; + ReconnectAndUploadSave(); + } + } + + private void DrawSettings(Rect entry, Rect inRect) + { + // Status + progress + if (!string.IsNullOrEmpty(statusText)) + { + var statusHeight = Text.CalcHeight(statusText, entry.width); + Widgets.Label(entry.Height(statusHeight), statusText); + entry = entry.Down(statusHeight + 4); + } + + if (isUploadingToml) + { + var barRect = entry.Height(20f); + Widgets.FillableBar(barRect, uploadProgress); + entry = entry.Down(24); + } + + // Tab buttons + using (MpStyle.Set(TextAnchor.MiddleLeft)) + { + DoTabButton(entry.Width(140).Height(40f), Tab.Connecting); + DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); + if (Prefs.DevMode) + DoTabButton(entry.Down(100f).Width(140).Height(40f), Tab.Preview); + } + + // Content based on selected tab + var contentRect = entry.MinX(entry.xMin + 150); + var buffers = new ServerSettingsUI.BufferSet + { + MaxPlayersBuffer = settingsUiBuffers.MaxPlayersBuffer, + AutosaveBuffer = settingsUiBuffers.AutosaveBuffer + }; + + if (tab == Tab.Connecting) + ServerSettingsUI.DrawNetworkingSettings(contentRect, settings, buffers); + else if (tab == Tab.Gameplay) + ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); + else if (tab == Tab.Preview) + { + RebuildTomlPreview(); + var previewRect = new Rect(contentRect.x, contentRect.y, contentRect.width, inRect.height - contentRect.y - 50f); + DrawTomlPreview(previewRect); + } + + // Sync buffers back + settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; + settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; + + // Buttons at bottom + DrawSettingsButtons(new Rect(0, inRect.height - 40f, inRect.width, 35f)); + } + + private void DoTabButton(Rect r, Tab tab) + { + Widgets.DrawOptionBackground(r, tab == this.tab); + if (Widgets.ButtonInvisible(r, true)) + { + this.tab = tab; + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + float num = r.x + 10f; + Texture2D icon; + string label; + + if (tab == Tab.Connecting) + { + icon = ContentFinder.Get("UI/Icons/Options/OptionsGeneral"); + label = "MpHostTabConnecting".Translate(); + } + else if (tab == Tab.Gameplay) + { + icon = ContentFinder.Get("UI/Icons/Options/OptionsGameplay"); + label = "MpHostTabGameplay".Translate(); + } + else + { + icon = null; + label = "Preview"; + } + + if (icon != null) + { + Rect rect = new Rect(num, r.y + (r.height - 20f) / 2f, 20f, 20f); + GUI.DrawTexture(rect, icon); + num += 30f; + } + + Widgets.Label(new Rect(num, r.y, r.width - num, r.height), label); + } + + private void DrawSettingsButtons(Rect inRect) + { + Rect nextRect; + if (Prefs.DevMode) + { + var copyRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); + if (Widgets.ButtonText(copyRect, "Copy TOML")) + { + RebuildTomlPreview(); + GUIUtility.systemCopyBuffer = tomlPreview; + Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); + } + nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); + } + else + { + nextRect = new Rect((inRect.width - 150f) / 2f, inRect.y, 150f, inRect.height); + } + + var nextLabel = settingsUploaded ? "Uploaded" : "Next"; + var nextEnabled = !isUploadingToml && !settingsUploaded; + + var prevColor = GUI.color; + if (!nextEnabled) + GUI.color = new Color(1f, 1f, 1f, 0.5f); + + if (Widgets.ButtonText(nextRect, nextLabel)) + { + if (nextEnabled) + { + RebuildTomlPreview(); + StartUploadSettingsToml(); + } + } + + GUI.color = prevColor; + } + + private void StartUploadSettingsToml() + { + isUploadingToml = true; + uploadProgress = 0f; + statusText = "Uploading server settings..."; + + new System.Threading.Thread(() => + { + try + { + connection.Send(new ClientBootstrapSettingsPacket(settings)); + + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + settingsUploaded = true; + statusText = "Server settings configured correctly. Proceed with map generation."; + step = Step.GenerateMap; + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + statusText = $"Failed to upload settings: {e.GetType().Name}: {e.Message}"; + }); + } + }) { IsBackground = true, Name = "MP Bootstrap settings upload" }.Start(); + } + + private void StartVanillaNewColonyFlow() + { + // Disconnect from server before world generation to avoid sync conflicts. + // We'll reconnect after the autosave is complete to upload save.zip. + if (Multiplayer.session != null) + { + Multiplayer.session.Stop(); + Multiplayer.session = null; + } + + try + { + Current.Game ??= new Game(); + Current.Game.InitData ??= new GameInitData { startedFromEntry = true }; + + // Ensure BootstrapCoordinator is added to the game components + if (Current.Game.components.All(c => c is not BootstrapCoordinator)) + { + Current.Game.components.Add(new BootstrapCoordinator(Current.Game)); + } + + var scenarioPage = new Page_SelectScenario(); + Find.WindowStack.Add(PageUtility.StitchedPages(new System.Collections.Generic.List { scenarioPage })); + + // Start watching for page flow + map entry + saveReady = false; + savedReplayPath = null; + autoAdvanceArmed = true; + nextPressCooldown = 0f; + randomTileCooldown = 0f; + autoAdvanceElapsed = 0f; + worldGenDetected = false; + worldGenDelayRemaining = WorldGenDelaySeconds; + autoAdvanceDiagCooldown = 0f; + startingLettersCleared = false; + landingDialogsCleared = false; + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Generating map..."; + } + catch (Exception e) + { + Messages.Message($"Failed to start New Colony flow: {e.GetType().Name}: {e.Message}", MessageTypeDefOf.RejectInput, false); + } + } + + private void TryArmAwaitingBootstrapMapInit(string source) + { + if (AwaitingBootstrapMapInit) + return; + + try + { + if (LongEventHandler.AnyEventNowOrWaiting) + return; + } + catch + { + // If the API isn't available, fail open. + } + + if (Current.ProgramState != ProgramState.Playing) + return; + + if (Find.Maps == null || Find.Maps.Count == 0) + return; + + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Entered map. Waiting for initialization to complete..."; + Log.Message($"[Bootstrap] Map init armed via {source}. maps={Find.Maps.Count}"); + + // Stop page driver + autoAdvanceArmed = false; + } + + internal void TryArmAwaitingBootstrapMapInit_FromRootPlay() + { + TryArmAwaitingBootstrapMapInit("Root_Play.Start"); + } + + internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() + { + TryArmAwaitingBootstrapMapInit("Root_Play.Update"); + + // Also drive the post-map save pipeline from this reliable update loop + TickPostMapEnterSaveDelayAndMaybeSave(); + } + + public void OnBootstrapMapInitialized() + { + Log.Message($"[Bootstrap] OnBootstrapMapInitialized CALLED - AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}"); + + if (!AwaitingBootstrapMapInit) + return; + + hideWindowDuringMapGen = false; + + AwaitingBootstrapMapInit = false; + postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + awaitingControllablePawns = true; + awaitingControllablePawnsElapsed = 0f; + bootstrapSaveQueued = false; + saveUploadStatus = "Map initialized. Waiting before saving..."; + + Log.Message($"[Bootstrap] Map initialized - awaiting colonists"); + } + + private void TickPostMapEnterSaveDelayAndMaybeSave() + { + if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) + return; + + if (postMapEnterSaveDelayRemaining <= 0f) + return; + + postMapEnterSaveDelayRemaining -= Time.deltaTime; + + if (postMapEnterSaveDelayRemaining > 0f) + return; + + // Wait until we actually have spawned pawns + if (awaitingControllablePawns) + { + awaitingControllablePawnsElapsed += Time.deltaTime; + + if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap != null) + { + var anyColonist = false; + try + { + anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null && + Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0; + } + catch (Exception ex) + { + Log.Error($"[Bootstrap] Exception checking for colonists: {ex.GetType().Name}: {ex.Message}"); + } + + if (anyColonist) + { + awaitingControllablePawns = false; + try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } + Log.Message("[Bootstrap] Controllable colonists detected, starting save"); + } + } + + if (awaitingControllablePawns) + { + if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) + { + awaitingControllablePawns = false; + Log.Warning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + } + else + { + saveUploadStatus = "Waiting for controllable colonists to spawn..."; + return; + } + } + } + + postMapEnterSaveDelayRemaining = 0f; + bootstrapSaveQueued = true; + saveUploadStatus = "Map initialized. Starting hosted MP session..."; + + Log.Message("[Bootstrap] All conditions met, initiating save sequence"); + + LongEventHandler.QueueLongEvent(() => + { + try + { + var hostSettings = new ServerSettings + { + gameName = settings.gameName, + maxPlayers = 2, + direct = false, + lan = false, + steam = false, + }; + + bool hosted = HostWindow.HostProgrammatically(hostSettings); + if (!hosted) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Failed to host MP session."; + Log.Error("[Bootstrap] HostProgrammatically failed"); + bootstrapSaveQueued = false; + }); + return; + } + + Log.Message("[Bootstrap] Hosted MP session successfully. Now saving replay..."); + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Hosted. Saving replay..."; + + LongEventHandler.QueueLongEvent(() => + { + try + { + Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); + + var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); + + OnMainThread.Enqueue(() => + { + savedReplayPath = path; + saveReady = File.Exists(savedReplayPath); + lastSavedReplayPath = savedReplayPath; + lastSaveReady = saveReady; + + if (saveReady) + { + saveUploadStatus = "Save created. Returning to menu..."; + + LongEventHandler.QueueLongEvent(() => + { + GenScene.GoToMainMenu(); + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Reconnecting to upload save..."; + ReconnectAndUploadSave(); + }); + }, "Returning to menu", false, null); + } + else + { + saveUploadStatus = $"Save finished but file not found: {path}"; + Log.Error($"[Bootstrap] Save finished but file missing: {savedReplayPath}"); + bootstrapSaveQueued = false; + } + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"[Bootstrap] Save failed: {e}"); + bootstrapSaveQueued = false; + }); + } + }, "Saving", false, null); + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"[Bootstrap] Host exception: {e}"); + bootstrapSaveQueued = false; + }); + } + }, "Starting host", false, null); + } + + public override void PreOpen() + { + base.PreOpen(); + UpdateWindowVisibility(); + } + + public override void WindowUpdate() + { + base.WindowUpdate(); + + UpdateWindowVisibility(); + + // Always try to drive the save delay + TickPostMapEnterSaveDelayAndMaybeSave(); + + // Poll LiteNet during reconnection + if (isReconnecting && Multiplayer.session?.netClient != null) + Multiplayer.session.netClient.PollEvents(); + + if (isReconnecting) + CheckReconnectionState(); + + // If we've reconnected and server indicates settings are missing, reset to settings step + if (!isReconnecting && Multiplayer.session?.serverBootstrapSettingsMissing == true && step == Step.GenerateMap) + { + step = Step.Settings; + settingsUploaded = false; + statusText = "Server settings.toml is missing. Configure and upload it."; + } + } + + private void UpdateWindowVisibility() + { + if (hideWindowDuringMapGen) + { + windowRect.width = 0; + windowRect.height = 0; + } + else + { + var size = InitialSize; + if (windowRect.width == 0) + { + windowRect.width = size.x; + windowRect.height = size.y; + windowRect.x = (UI.screenWidth - size.x) / 2f; + windowRect.y = (UI.screenHeight - size.y) / 2f; + } + } + } + + /// + /// Called by once per second while the bootstrap window exists. + /// + internal void BootstrapCoordinatorTick() + { + if (!AwaitingBootstrapMapInit) + TryArmAwaitingBootstrapMapInit("BootstrapCoordinator"); + + TickPostMapEnterSaveDelayAndMaybeSave(); + } + + private void ReconnectAndUploadSave() + { + saveUploadStatus = "Reconnecting to server..."; + + try + { + Multiplayer.StopMultiplayer(); + + Multiplayer.session = new MultiplayerSession + { + address = serverAddress, + port = serverPort + }; + + var netClient = new NetManager(new MpClientNetListener()) + { + EnableStatistics = true, + IPv6Enabled = MpUtil.SupportsIPv6() ? IPv6Mode.SeparateSocket : IPv6Mode.Disabled + }; + netClient.Start(); + netClient.ReconnectDelay = 300; + netClient.MaxConnectAttempts = 8; + + Multiplayer.session.netClient = netClient; + netClient.Connect(serverAddress, serverPort, ""); + + isReconnecting = true; + reconnectCheckTimer = 0; + } + catch (Exception e) + { + saveUploadStatus = $"Reconnection failed: {e.GetType().Name}: {e.Message}"; + isUploadingSave = false; + } + } + + private void CheckReconnectionState() + { + reconnectCheckTimer++; + + if (Multiplayer.Client?.State == ConnectionStateEnum.ClientBootstrap) + { + saveUploadStatus = "Reconnected. Starting upload..."; + isReconnecting = false; + reconnectCheckTimer = 0; + StartUploadSaveZip(); + } + else if (Multiplayer.Client?.State == ConnectionStateEnum.Disconnected || (Multiplayer.Client == null && reconnectCheckTimer > 300)) + { + saveUploadStatus = "Reconnection failed. Cannot upload save.zip."; + isReconnecting = false; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + else if (reconnectCheckTimer > 600) // ~10 seconds at 60fps + { + saveUploadStatus = "Reconnection timeout. Cannot upload save.zip."; + isReconnecting = false; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + } + + private void StartUploadSaveZip() + { + if (string.IsNullOrWhiteSpace(savedReplayPath) || !File.Exists(savedReplayPath)) + { + saveUploadStatus = "Can't upload: autosave file not found."; + return; + } + + isUploadingSave = true; + saveUploadProgress = 0f; + saveUploadStatus = "Uploading save.zip..."; + + byte[] bytes; + try + { + bytes = File.ReadAllBytes(savedReplayPath); + } + catch (Exception e) + { + isUploadingSave = false; + saveUploadStatus = $"Failed to read autosave: {e.GetType().Name}: {e.Message}"; + return; + } + + var targetConn = Multiplayer.Client; + if (targetConn == null) + { + isUploadingSave = false; + saveUploadStatus = "No active connection. Cannot upload."; + return; + } + + new System.Threading.Thread(() => + { + try + { + targetConn.Send(new ClientBootstrapSaveStartPacket("save.zip", bytes.Length)); + + const int chunk = 256 * 1024; + var sent = 0; + while (sent < bytes.Length) + { + var len = Math.Min(chunk, bytes.Length - sent); + var part = new byte[len]; + Buffer.BlockCopy(bytes, sent, part, 0, len); + targetConn.SendFragmented(new ClientBootstrapSaveDataPacket(part).Serialize()); + sent += len; + var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; + OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); + } + + byte[] sha256Hash; + using (var hasher = SHA256.Create()) + sha256Hash = hasher.ComputeHash(bytes); + + targetConn.Send(new ClientBootstrapSaveEndPacket(sha256Hash)); + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Upload finished. Waiting for server to confirm and shut down..."; + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + isUploadingSave = false; + saveUploadStatus = $"Failed to upload save.zip: {e.GetType().Name}: {e.Message}"; + }); + } + }) { IsBackground = true, Name = "MP Bootstrap save upload" }.Start(); + } + + private void DrawTomlPreview(Rect inRect) + { + Widgets.DrawMenuSection(inRect); + var inner = inRect.ContractedBy(10f); + + Text.Font = GameFont.Small; + Widgets.Label(inner.TopPartPixels(22f), "settings.toml preview"); + + var previewRect = new Rect(inner.x, inner.y + 26f, inner.width, inner.height - 26f); + var content = tomlPreview ?? ""; + + var viewRect = new Rect(0f, 0f, previewRect.width - 16f, Mathf.Max(previewRect.height, Text.CalcHeight(content, previewRect.width - 16f) + 20f)); + Widgets.BeginScrollView(previewRect, ref tomlScroll, viewRect); + Widgets.Label(new Rect(0f, 0f, viewRect.width, viewRect.height), content); + Widgets.EndScrollView(); + } + + private void RebuildTomlPreview() + { + var sb = new StringBuilder(); + + sb.AppendLine("# Generated by Multiplayer bootstrap configurator"); + sb.AppendLine("# Keys must match ServerSettings.ExposeData()\n"); + + AppendKv(sb, "directAddress", settings.directAddress); + AppendKv(sb, "maxPlayers", settings.maxPlayers); + AppendKv(sb, "autosaveInterval", settings.autosaveInterval); + AppendKv(sb, "autosaveUnit", settings.autosaveUnit.ToString()); + AppendKv(sb, "steam", settings.steam); + AppendKv(sb, "direct", settings.direct); + AppendKv(sb, "lan", settings.lan); + AppendKv(sb, "asyncTime", settings.asyncTime); + AppendKv(sb, "multifaction", settings.multifaction); + AppendKv(sb, "debugMode", settings.debugMode); + AppendKv(sb, "desyncTraces", settings.desyncTraces); + AppendKv(sb, "syncConfigs", settings.syncConfigs); + AppendKv(sb, "autoJoinPoint", settings.autoJoinPoint.ToString()); + AppendKv(sb, "devModeScope", settings.devModeScope.ToString()); + AppendKv(sb, "hasPassword", settings.hasPassword); + AppendKv(sb, "password", settings.password ?? ""); + AppendKv(sb, "pauseOnLetter", settings.pauseOnLetter.ToString()); + AppendKv(sb, "pauseOnJoin", settings.pauseOnJoin); + AppendKv(sb, "pauseOnDesync", settings.pauseOnDesync); + AppendKv(sb, "timeControl", settings.timeControl.ToString()); + + tomlPreview = sb.ToString(); + } + + private static void AppendKv(StringBuilder sb, string key, string value) + { + sb.Append(key); + sb.Append(" = "); + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + sb.Append('"').Append(escaped).Append('"'); + sb.AppendLine(); + } + + private static void AppendKv(StringBuilder sb, string key, bool value) + { + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value ? "true" : "false"); + } + + private static void AppendKv(StringBuilder sb, string key, int value) + { + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString()); + } + + private static void AppendKv(StringBuilder sb, string key, float value) + { + sb.Append(key); + sb.Append(" = "); + sb.AppendLine(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } +} diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index fbe44321c..a5792d960 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -290,5 +290,17 @@ private void HostFromReplay(ServerSettings settings) ReplayLoaded(); } } + + /// + /// Programmatically host a multiplayer game from the current single-player state + /// without showing the host window. Used by the bootstrap flow. + /// + public static bool HostProgrammatically(ServerSettings settings) + { + var localServer = new MultiplayerServer(settings); + Multiplayer.LocalServer = localServer; + HostUtil.HostServer(settings, false); + return true; + } } } diff --git a/Source/Client/Windows/ServerSettingsUI.cs b/Source/Client/Windows/ServerSettingsUI.cs index c9314fdd5..ac6fa95b2 100644 --- a/Source/Client/Windows/ServerSettingsUI.cs +++ b/Source/Client/Windows/ServerSettingsUI.cs @@ -72,6 +72,12 @@ public static void DrawNetworkingSettings(Rect entry, ServerSettings settings, B MpUI.CheckboxLabeled(entry.Width(CheckboxWidth), $"{"MpSyncConfigs".Translate()}: ", ref settings.syncConfigs, order: ElementOrder.Right); } + /// + /// Draw gameplay-related settings without lock parameters. Used by bootstrap configurator. + /// + public static void DrawGameplaySettingsOnly(Rect entry, ServerSettings settings, BufferSet buffers) + => DrawGameplaySettings(entry, settings, buffers); + /// /// Draw gameplay-related settings (autosave, multifaction, async time, time control, etc.). /// diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index ad5362705..180680a91 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -12,6 +12,7 @@ namespace Multiplayer.Common; /// 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. /// +[PacketHandlerClass] public class ServerBootstrapState(ConnectionBase conn) : MpConnectionState(conn) { // Only one configurator at a time; track by username to survive reconnections diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index ded55a57e..d8b79c295 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -4,6 +4,7 @@ namespace Multiplayer.Common; +[PacketHandlerClass] public class ServerJoiningState : AsyncConnectionState { public ServerJoiningState(ConnectionBase connection) : base(connection) diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index 0b5032074..0e602a140 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -3,6 +3,7 @@ namespace Multiplayer.Common; +[PacketHandlerClass] public class ServerLoadingState : AsyncConnectionState { public ServerLoadingState(ConnectionBase connection) : base(connection) diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 04a65728a..bc832d57a 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -4,6 +4,7 @@ namespace Multiplayer.Common { + [PacketHandlerClass] public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) { [PacketHandler(Packets.Client_WorldReady)] diff --git a/Source/Common/Networking/State/ServerSteamState.cs b/Source/Common/Networking/State/ServerSteamState.cs index 414094a8f..214ff53df 100644 --- a/Source/Common/Networking/State/ServerSteamState.cs +++ b/Source/Common/Networking/State/ServerSteamState.cs @@ -1,6 +1,7 @@ namespace Multiplayer.Common { // Unused + [PacketHandlerClass] public class ServerSteamState : MpConnectionState { public ServerSteamState(ConnectionBase conn) : base(conn) From 75cf012bff380d4016bc09f2d3019ef8058d083e Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 26 Mar 2026 22:56:37 +0100 Subject: [PATCH 2/4] Bootstrap: split protocol and state handling --- Source/Client/MultiplayerStatic.cs | 1 + Source/Client/Networking/State/ClientBaseState.cs | 2 +- .../Client/Networking/State/ClientBootstrapState.cs | 11 +++++++++-- .../Common/Networking/State/ServerBootstrapState.cs | 8 ++++++++ Source/Common/PlayerManager.cs | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 0eed8d81e..fb27a70aa 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -78,6 +78,7 @@ static MultiplayerStatic() MpConnectionState.SetImplementation(ConnectionStateEnum.ClientJoining, typeof(ClientJoiningState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ClientLoading, typeof(ClientLoadingState)); MpConnectionState.SetImplementation(ConnectionStateEnum.ClientPlaying, typeof(ClientPlayingState)); + MpConnectionState.SetImplementation(ConnectionStateEnum.ClientBootstrap, typeof(ClientBootstrapState)); MultiplayerData.CollectCursorIcons(); diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 66b22f680..38e02c842 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -10,7 +10,7 @@ public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionS protected MultiplayerSession Session => Multiplayer.session; [TypedPacketHandler] - public void HandleDisconnected(ServerDisconnectPacket packet) + public virtual void HandleDisconnected(ServerDisconnectPacket packet) { ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet.reason, new ByteReader(packet.data))); Multiplayer.StopMultiplayer(); diff --git a/Source/Client/Networking/State/ClientBootstrapState.cs b/Source/Client/Networking/State/ClientBootstrapState.cs index a20fe152c..d86e4a98e 100644 --- a/Source/Client/Networking/State/ClientBootstrapState.cs +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -10,11 +10,18 @@ namespace Multiplayer.Client; /// The server is in ServerBootstrap and expects upload packets; the client must keep the connection alive /// and handle bootstrap completion / disconnect packets. /// -[PacketHandlerClass(inheritHandlers: true)] +[PacketHandlerClass] public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) { [TypedPacketHandler] - public new void HandleDisconnected(ServerDisconnectPacket packet) + public void HandleBootstrap(ServerBootstrapPacket packet) + { + // The server sends this again when entering ServerBootstrapState.StartState(). + // We already have the bootstrap info from ClientJoiningState; just ignore it. + } + + [TypedPacketHandler] + public override void HandleDisconnected(ServerDisconnectPacket packet) { // If bootstrap completed successfully, show success message before closing the window if (packet.reason == MpDisconnectReason.BootstrapCompleted) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index 180680a91..0ecbde623 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -181,6 +181,14 @@ public void HandleSaveEnd(ClientBootstrapSaveEndPacket packet) private bool IsConfigurator() => configuratorUsername == connection.username; + [PacketHandler(Packets.Client_Cursor)] + public void HandleCursor(ByteReader data) + { + // Drain the packet so the "not fully consumed" check passes + data.ReadByte(); // seq + data.ReadByte(); // map + } + private static void ResetUploadState() { pendingFileName = null; diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index 13bf67e11..7eaa31318 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -37,7 +37,7 @@ public void SendLatencies() // id can be an IPAddress or CSteamID public MpDisconnectReason? OnPreConnect(object id) { - if (server.FullyStarted is false) + if (server.FullyStarted is false && !server.BootstrapMode) return MpDisconnectReason.ServerStarting; if (id is IPAddress addr && IPAddress.IsLoopback(addr)) From 58c9f38c55c49466473393fc640f23debcb0c099 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 26 Mar 2026 22:56:43 +0100 Subject: [PATCH 3/4] Bootstrap: harden client configuration flow --- Source/Client/AsyncTime/SetMapTime.cs | 8 +- Source/Client/OnMainThread.cs | 4 + .../Windows/BootstrapConfiguratorWindow.cs | 75 +++++++------------ 3 files changed, 38 insertions(+), 49 deletions(-) diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index fb1044aea..3b40f57f3 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -205,11 +205,15 @@ public static TimeSnapshot Current() { if (map == null) return null; - TimeSnapshot prev = Current(); - var tickManager = Find.TickManager; + if (tickManager == null) return null; + + TimeSnapshot prev = Current(); var mapComp = map.AsyncTime(); + if (mapComp == null) + return prev; + tickManager.ticksGameInt = mapComp.mapTicks; tickManager.slower = mapComp.slower; tickManager.CurTimeSpeed = mapComp.DesiredTimeSpeed; diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index f6d0e0999..90f3ff303 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -27,6 +27,10 @@ public void Update() { Multiplayer.session?.netClient?.PollEvents(); } + catch (InvalidOperationException e) when (e.Message == "Queue empty." || e.Message == "Coda vuota.") + { + // LiteNetLib can throw here during reconnect/disconnect teardown when its event queue races empty. + } catch (Exception e) { Log.Error($"Exception handling network events: {e}"); diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index b792ee6a4..dba806681 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -48,8 +48,6 @@ private enum Tab private Step step; private Tab tab; - private Vector2 scroll; - // UI buffers private ServerSettingsUI.BufferSet settingsUiBuffers = new(); @@ -79,18 +77,6 @@ private enum Tab // Vanilla page auto-advance during bootstrap private bool autoAdvanceArmed; - private float nextPressCooldown; - private float randomTileCooldown; - private const float NextPressCooldownSeconds = 0.45f; - private const float RandomTileCooldownSeconds = 0.9f; - private const float AutoAdvanceTimeoutSeconds = 180f; - private float autoAdvanceElapsed; - private bool worldGenDetected; - private float worldGenDelayRemaining; - private const float WorldGenDelaySeconds = 1f; - - private float autoAdvanceDiagCooldown; - private const float AutoAdvanceDiagCooldownSeconds = 2.0f; // Delay before saving after entering the map private float postMapEnterSaveDelayRemaining; @@ -103,8 +89,6 @@ private enum Tab private bool awaitingControllablePawns; private float awaitingControllablePawnsElapsed; private const float AwaitControllablePawnsTimeoutSeconds = 30f; - private bool startingLettersCleared; - private bool landingDialogsCleared; // Static flag to track bootstrap map initialization public static bool AwaitingBootstrapMapInit; @@ -159,7 +143,6 @@ public BootstrapConfiguratorWindow(ConnectionBase connection) // Check if we have a previously saved Bootstrap.zip from this session (reconnect case) if (!autoUploadAttempted && lastSaveReady && !string.IsNullOrEmpty(lastSavedReplayPath) && File.Exists(lastSavedReplayPath)) { - Log.Message($"[Bootstrap] Found previous Bootstrap.zip at {lastSavedReplayPath}, auto-uploading..."); savedReplayPath = lastSavedReplayPath; saveReady = true; saveUploadStatus = "Save ready from previous session. Uploading..."; @@ -255,7 +238,7 @@ private void DrawGenerateMap(Rect entry, Rect inRect) } // Auto-start upload when save is ready - if (saveReady && !isUploadingSave && !saveUploadAutoStarted) + if (saveReady && !isUploadingSave && !saveUploadAutoStarted && Multiplayer.Client == null && Current.ProgramState != ProgramState.Playing) { saveUploadAutoStarted = true; ReconnectAndUploadSave(); @@ -410,10 +393,15 @@ private void StartUploadSettingsToml() settingsUploaded = true; statusText = "Server settings configured correctly. Proceed with map generation."; step = Step.GenerateMap; + + // Clear the flag so WindowUpdate() doesn't reset step back to Settings + if (Multiplayer.session != null) + Multiplayer.session.serverBootstrapSettingsMissing = false; }); } catch (Exception e) { + Log.Error($"Bootstrap settings upload failed: {e}"); OnMainThread.Enqueue(() => { isUploadingToml = false; @@ -451,14 +439,6 @@ private void StartVanillaNewColonyFlow() saveReady = false; savedReplayPath = null; autoAdvanceArmed = true; - nextPressCooldown = 0f; - randomTileCooldown = 0f; - autoAdvanceElapsed = 0f; - worldGenDetected = false; - worldGenDelayRemaining = WorldGenDelaySeconds; - autoAdvanceDiagCooldown = 0f; - startingLettersCleared = false; - landingDialogsCleared = false; AwaitingBootstrapMapInit = true; saveUploadStatus = "Generating map..."; } @@ -468,11 +448,15 @@ private void StartVanillaNewColonyFlow() } } - private void TryArmAwaitingBootstrapMapInit(string source) + private void TryArmAwaitingBootstrapMapInit() { if (AwaitingBootstrapMapInit) return; + // Once a local hosted MP session exists, bootstrap map-init detection must stop. + if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting || saveUploadAutoStarted) + return; + try { if (LongEventHandler.AnyEventNowOrWaiting) @@ -491,20 +475,23 @@ private void TryArmAwaitingBootstrapMapInit(string source) AwaitingBootstrapMapInit = true; saveUploadStatus = "Entered map. Waiting for initialization to complete..."; - Log.Message($"[Bootstrap] Map init armed via {source}. maps={Find.Maps.Count}"); // Stop page driver autoAdvanceArmed = false; + + // Re-add to WindowStack if missing (Root_Play transition clears all windows) + if (Find.WindowStack != null && Find.WindowStack.WindowOfType() == null) + Find.WindowStack.Add(this); } internal void TryArmAwaitingBootstrapMapInit_FromRootPlay() { - TryArmAwaitingBootstrapMapInit("Root_Play.Start"); + TryArmAwaitingBootstrapMapInit(); } internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() { - TryArmAwaitingBootstrapMapInit("Root_Play.Update"); + TryArmAwaitingBootstrapMapInit(); // Also drive the post-map save pipeline from this reliable update loop TickPostMapEnterSaveDelayAndMaybeSave(); @@ -512,8 +499,6 @@ internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() public void OnBootstrapMapInitialized() { - Log.Message($"[Bootstrap] OnBootstrapMapInitialized CALLED - AwaitingBootstrapMapInit={AwaitingBootstrapMapInit}"); - if (!AwaitingBootstrapMapInit) return; @@ -525,8 +510,6 @@ public void OnBootstrapMapInitialized() awaitingControllablePawnsElapsed = 0f; bootstrapSaveQueued = false; saveUploadStatus = "Map initialized. Waiting before saving..."; - - Log.Message($"[Bootstrap] Map initialized - awaiting colonists"); } private void TickPostMapEnterSaveDelayAndMaybeSave() @@ -534,7 +517,8 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) return; - if (postMapEnterSaveDelayRemaining <= 0f) + // Skip if timer was never started (default 0) AND we're not waiting for pawns + if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns) return; postMapEnterSaveDelayRemaining -= Time.deltaTime; @@ -557,14 +541,13 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() } catch (Exception ex) { - Log.Error($"[Bootstrap] Exception checking for colonists: {ex.GetType().Name}: {ex.Message}"); + Log.Error($"Exception checking for controllable colonists: {ex.GetType().Name}: {ex.Message}"); } if (anyColonist) { awaitingControllablePawns = false; try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } - Log.Message("[Bootstrap] Controllable colonists detected, starting save"); } } @@ -573,7 +556,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) { awaitingControllablePawns = false; - Log.Warning("[Bootstrap] Timed out waiting for controllable pawns; saving anyway"); + Log.Warning("Timed out waiting for controllable pawns during bootstrap; saving anyway"); } else { @@ -587,8 +570,6 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() bootstrapSaveQueued = true; saveUploadStatus = "Map initialized. Starting hosted MP session..."; - Log.Message("[Bootstrap] All conditions met, initiating save sequence"); - LongEventHandler.QueueLongEvent(() => { try @@ -608,14 +589,12 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() OnMainThread.Enqueue(() => { saveUploadStatus = "Failed to host MP session."; - Log.Error("[Bootstrap] HostProgrammatically failed"); + Log.Error("HostProgrammatically failed during bootstrap save creation"); bootstrapSaveQueued = false; }); return; } - Log.Message("[Bootstrap] Hosted MP session successfully. Now saving replay..."); - OnMainThread.Enqueue(() => { saveUploadStatus = "Hosted. Saving replay..."; @@ -637,6 +616,8 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (saveReady) { + // Prevent DrawGenerateMap from reconnecting before we've returned to the menu. + saveUploadAutoStarted = true; saveUploadStatus = "Save created. Returning to menu..."; LongEventHandler.QueueLongEvent(() => @@ -653,7 +634,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() else { saveUploadStatus = $"Save finished but file not found: {path}"; - Log.Error($"[Bootstrap] Save finished but file missing: {savedReplayPath}"); + Log.Error($"Bootstrap save finished but file was missing: {savedReplayPath}"); bootstrapSaveQueued = false; } }); @@ -663,7 +644,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() OnMainThread.Enqueue(() => { saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}"; - Log.Error($"[Bootstrap] Save failed: {e}"); + Log.Error($"Bootstrap save failed: {e}"); bootstrapSaveQueued = false; }); } @@ -675,7 +656,7 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() OnMainThread.Enqueue(() => { saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}"; - Log.Error($"[Bootstrap] Host exception: {e}"); + Log.Error($"Bootstrap host exception: {e}"); bootstrapSaveQueued = false; }); } @@ -739,7 +720,7 @@ private void UpdateWindowVisibility() internal void BootstrapCoordinatorTick() { if (!AwaitingBootstrapMapInit) - TryArmAwaitingBootstrapMapInit("BootstrapCoordinator"); + TryArmAwaitingBootstrapMapInit(); TickPostMapEnterSaveDelayAndMaybeSave(); } From e0d495ebbdf075981a95f9372ba1cdead0458b64 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 27 Mar 2026 08:48:47 +0100 Subject: [PATCH 4/4] Split bootstrap configurator window --- ...otstrapConfiguratorWindow.BootstrapFlow.cs | 463 ++++++++++++ .../BootstrapConfiguratorWindow.SettingsUi.cs | 200 +++++ .../Windows/BootstrapConfiguratorWindow.cs | 708 +----------------- 3 files changed, 664 insertions(+), 707 deletions(-) create mode 100644 Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs create mode 100644 Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs new file mode 100644 index 000000000..06e4bc3a2 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -0,0 +1,463 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using LiteNetLib; +using Multiplayer.Client.Comp; +using Multiplayer.Client.Networking; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client +{ + public partial class BootstrapConfiguratorWindow + { + private void DrawGenerateMap(Rect entry, Rect inRect) + { + Text.Font = GameFont.Small; + var statusHeight = Text.CalcHeight(statusText ?? string.Empty, entry.width); + Widgets.Label(entry.Height(statusHeight), statusText ?? string.Empty); + entry = entry.Down(statusHeight + 10); + + if (!AwaitingBootstrapMapInit && !saveReady && !isUploadingSave && !isReconnecting) + { + DrawFactionOwnershipNotice(entry.Height(100f)); + entry = entry.Down(110); + } + + if (!string.IsNullOrEmpty(saveUploadStatus)) + { + var saveStatusHeight = Text.CalcHeight(saveUploadStatus, entry.width); + Widgets.Label(entry.Height(saveStatusHeight), saveUploadStatus); + entry = entry.Down(saveStatusHeight + 4); + } + + if (autoAdvanceArmed || isUploadingSave) + { + var barRect = entry.Height(18f); + Widgets.FillableBar(barRect, isUploadingSave ? saveUploadProgress : 0.1f); + entry = entry.Down(24); + } + + if (ShouldShowGenerateMapButton() && Widgets.ButtonText(new Rect((inRect.width - 200f) / 2f, inRect.height - 45f, 200f, 40f), "Generate map")) + { + saveUploadAutoStarted = false; + hideWindowDuringMapGen = true; + StartVanillaNewColonyFlow(); + } + + if (ShouldAutoReconnectForSaveUpload()) + { + saveUploadAutoStarted = true; + ReconnectAndUploadSave(); + } + } + + private void DrawFactionOwnershipNotice(Rect noticeRect) + { + GUI.color = new Color(1f, 0.85f, 0.5f); + Widgets.DrawBoxSolid(noticeRect, new Color(0.3f, 0.25f, 0.1f, 0.5f)); + GUI.color = Color.white; + + var noticeTextRect = noticeRect.ContractedBy(8f); + Text.Font = GameFont.Tiny; + GUI.color = new Color(1f, 0.9f, 0.6f); + Widgets.Label(noticeTextRect, + "IMPORTANT: The user who generates this map will own the main faction (colony).\n" + + "When setting up the server, make sure this user's username is listed as the host.\n" + + "Other players connecting to the server will be assigned as spectators or secondary factions."); + GUI.color = Color.white; + Text.Font = GameFont.Small; + } + + private bool ShouldShowGenerateMapButton() + { + return !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); + } + + private bool ShouldAutoReconnectForSaveUpload() + { + return saveReady && !isUploadingSave && !saveUploadAutoStarted && Multiplayer.Client == null && Current.ProgramState != ProgramState.Playing; + } + + private void StartVanillaNewColonyFlow() + { + if (Multiplayer.session != null) + { + Multiplayer.session.Stop(); + Multiplayer.session = null; + } + + try + { + Current.Game ??= new Game(); + Current.Game.InitData ??= new GameInitData { startedFromEntry = true }; + + if (Current.Game.components.All(c => c is not BootstrapCoordinator)) + Current.Game.components.Add(new BootstrapCoordinator(Current.Game)); + + var scenarioPage = new Page_SelectScenario(); + Find.WindowStack.Add(PageUtility.StitchedPages(new System.Collections.Generic.List { scenarioPage })); + + saveReady = false; + savedReplayPath = null; + autoAdvanceArmed = true; + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Generating map..."; + } + catch (Exception e) + { + Messages.Message($"Failed to start New Colony flow: {e.GetType().Name}: {e.Message}", MessageTypeDefOf.RejectInput, false); + } + } + + private void TryArmAwaitingBootstrapMapInit() + { + if (AwaitingBootstrapMapInit) + return; + + if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting || saveUploadAutoStarted) + return; + + try + { + if (LongEventHandler.AnyEventNowOrWaiting) + return; + } + catch + { + } + + if (Current.ProgramState != ProgramState.Playing) + return; + + if (Find.Maps == null || Find.Maps.Count == 0) + return; + + AwaitingBootstrapMapInit = true; + saveUploadStatus = "Entered map. Waiting for initialization to complete..."; + autoAdvanceArmed = false; + + if (Find.WindowStack != null && Find.WindowStack.WindowOfType() == null) + Find.WindowStack.Add(this); + } + + internal void TryArmAwaitingBootstrapMapInit_FromRootPlay() + { + TryArmAwaitingBootstrapMapInit(); + } + + internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() + { + TryArmAwaitingBootstrapMapInit(); + TickPostMapEnterSaveDelayAndMaybeSave(); + } + + public void OnBootstrapMapInitialized() + { + if (!AwaitingBootstrapMapInit) + return; + + hideWindowDuringMapGen = false; + AwaitingBootstrapMapInit = false; + postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + awaitingControllablePawns = true; + awaitingControllablePawnsElapsed = 0f; + bootstrapSaveQueued = false; + saveUploadStatus = "Map initialized. Waiting before saving..."; + } + + private void TickPostMapEnterSaveDelayAndMaybeSave() + { + if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) + return; + + if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns) + return; + + postMapEnterSaveDelayRemaining -= Time.deltaTime; + if (postMapEnterSaveDelayRemaining > 0f) + return; + + if (!WaitForControllableColonists()) + return; + + postMapEnterSaveDelayRemaining = 0f; + bootstrapSaveQueued = true; + saveUploadStatus = "Map initialized. Starting hosted MP session..."; + + LongEventHandler.QueueLongEvent(StartHostedBootstrapSaveCreation, "Starting host", false, null); + } + + private bool WaitForControllableColonists() + { + if (!awaitingControllablePawns) + return true; + + awaitingControllablePawnsElapsed += Time.deltaTime; + + if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap != null) + { + var anyColonist = false; + try + { + anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null + && Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0; + } + catch (Exception ex) + { + Log.Error($"Exception checking for controllable colonists: {ex.GetType().Name}: {ex.Message}"); + } + + if (anyColonist) + { + awaitingControllablePawns = false; + try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } + } + } + + if (!awaitingControllablePawns) + return true; + + if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) + { + awaitingControllablePawns = false; + Log.Warning("Timed out waiting for controllable pawns during bootstrap; saving anyway"); + return true; + } + + saveUploadStatus = "Waiting for controllable colonists to spawn..."; + return false; + } + + private void StartHostedBootstrapSaveCreation() + { + try + { + var hostSettings = new ServerSettings + { + gameName = settings.gameName, + maxPlayers = 2, + direct = false, + lan = false, + steam = false, + }; + + if (!HostWindow.HostProgrammatically(hostSettings)) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Failed to host MP session."; + Log.Error("HostProgrammatically failed during bootstrap save creation"); + bootstrapSaveQueued = false; + }); + return; + } + + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Hosted. Saving replay..."; + LongEventHandler.QueueLongEvent(CreateBootstrapReplaySave, "Saving", false, null); + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"Bootstrap host exception: {e}"); + bootstrapSaveQueued = false; + }); + } + } + + private void CreateBootstrapReplaySave() + { + try + { + Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); + var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); + OnMainThread.Enqueue(() => FinalizeBootstrapSave(path)); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}"; + Log.Error($"Bootstrap save failed: {e}"); + bootstrapSaveQueued = false; + }); + } + } + + private void FinalizeBootstrapSave(string path) + { + savedReplayPath = path; + saveReady = File.Exists(savedReplayPath); + lastSavedReplayPath = savedReplayPath; + lastSaveReady = saveReady; + + if (!saveReady) + { + saveUploadStatus = $"Save finished but file not found: {path}"; + Log.Error($"Bootstrap save finished but file was missing: {savedReplayPath}"); + bootstrapSaveQueued = false; + return; + } + + saveUploadAutoStarted = true; + saveUploadStatus = "Save created. Returning to menu..."; + LongEventHandler.QueueLongEvent(ReturnToMenuAndReconnect, "Returning to menu", false, null); + } + + private void ReturnToMenuAndReconnect() + { + GenScene.GoToMainMenu(); + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Reconnecting to upload save..."; + ReconnectAndUploadSave(); + }); + } + + private void ReconnectAndUploadSave() + { + saveUploadStatus = "Reconnecting to server..."; + + try + { + Multiplayer.StopMultiplayer(); + + Multiplayer.session = new MultiplayerSession + { + address = serverAddress, + port = serverPort + }; + + var netClient = new NetManager(new MpClientNetListener()) + { + EnableStatistics = true, + IPv6Enabled = MpUtil.SupportsIPv6() ? IPv6Mode.SeparateSocket : IPv6Mode.Disabled + }; + netClient.Start(); + netClient.ReconnectDelay = 300; + netClient.MaxConnectAttempts = 8; + + Multiplayer.session.netClient = netClient; + netClient.Connect(serverAddress, serverPort, string.Empty); + + isReconnecting = true; + reconnectCheckTimer = 0; + } + catch (Exception e) + { + saveUploadStatus = $"Reconnection failed: {e.GetType().Name}: {e.Message}"; + isUploadingSave = false; + } + } + + private void CheckReconnectionState() + { + reconnectCheckTimer++; + + if (Multiplayer.Client?.State == ConnectionStateEnum.ClientBootstrap) + { + saveUploadStatus = "Reconnected. Starting upload..."; + isReconnecting = false; + reconnectCheckTimer = 0; + StartUploadSaveZip(); + } + else if (Multiplayer.Client?.State == ConnectionStateEnum.Disconnected || (Multiplayer.Client == null && reconnectCheckTimer > 300)) + { + saveUploadStatus = "Reconnection failed. Cannot upload save.zip."; + isReconnecting = false; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + else if (reconnectCheckTimer > 600) + { + saveUploadStatus = "Reconnection timeout. Cannot upload save.zip."; + isReconnecting = false; + reconnectCheckTimer = 0; + isUploadingSave = false; + } + } + + private void StartUploadSaveZip() + { + if (string.IsNullOrWhiteSpace(savedReplayPath) || !File.Exists(savedReplayPath)) + { + saveUploadStatus = "Can't upload: autosave file not found."; + return; + } + + isUploadingSave = true; + saveUploadProgress = 0f; + saveUploadStatus = "Uploading save.zip..."; + + byte[] bytes; + try + { + bytes = File.ReadAllBytes(savedReplayPath); + } + catch (Exception e) + { + isUploadingSave = false; + saveUploadStatus = $"Failed to read autosave: {e.GetType().Name}: {e.Message}"; + return; + } + + var targetConn = Multiplayer.Client; + if (targetConn == null) + { + isUploadingSave = false; + saveUploadStatus = "No active connection. Cannot upload."; + return; + } + + new System.Threading.Thread(() => + { + try + { + targetConn.Send(new ClientBootstrapSaveStartPacket("save.zip", bytes.Length)); + + const int chunk = 256 * 1024; + var sent = 0; + while (sent < bytes.Length) + { + var len = Math.Min(chunk, bytes.Length - sent); + var part = new byte[len]; + Buffer.BlockCopy(bytes, sent, part, 0, len); + targetConn.SendFragmented(new ClientBootstrapSaveDataPacket(part).Serialize()); + sent += len; + var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; + OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); + } + + byte[] sha256Hash; + using (var hasher = SHA256.Create()) + sha256Hash = hasher.ComputeHash(bytes); + + targetConn.Send(new ClientBootstrapSaveEndPacket(sha256Hash)); + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Upload finished. Waiting for server to confirm and shut down..."; + }); + } + catch (Exception e) + { + OnMainThread.Enqueue(() => + { + isUploadingSave = false; + saveUploadStatus = $"Failed to upload save.zip: {e.GetType().Name}: {e.Message}"; + }); + } + }) { IsBackground = true, Name = "MP Bootstrap save upload" }.Start(); + } + } +} \ No newline at end of file diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs new file mode 100644 index 000000000..370f30e14 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs @@ -0,0 +1,200 @@ +using System; +using Multiplayer.Client.Util; +using Multiplayer.Common.Networking.Packet; +using Multiplayer.Common.Util; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client +{ + public partial class BootstrapConfiguratorWindow + { + private void DrawSettings(Rect entry, Rect inRect) + { + if (!string.IsNullOrEmpty(statusText)) + { + var statusHeight = Text.CalcHeight(statusText, entry.width); + Widgets.Label(entry.Height(statusHeight), statusText); + entry = entry.Down(statusHeight + 4); + } + + if (isUploadingToml) + { + var barRect = entry.Height(20f); + Widgets.FillableBar(barRect, uploadProgress); + entry = entry.Down(24); + } + + using (MpStyle.Set(TextAnchor.MiddleLeft)) + { + DoTabButton(entry.Width(140).Height(40f), Tab.Connecting); + DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); + if (Prefs.DevMode) + DoTabButton(entry.Down(100f).Width(140).Height(40f), Tab.Preview); + } + + var contentRect = entry.MinX(entry.xMin + 150); + var buffers = new ServerSettingsUI.BufferSet + { + MaxPlayersBuffer = settingsUiBuffers.MaxPlayersBuffer, + AutosaveBuffer = settingsUiBuffers.AutosaveBuffer + }; + + if (tab == Tab.Connecting) + ServerSettingsUI.DrawNetworkingSettings(contentRect, settings, buffers); + else if (tab == Tab.Gameplay) + ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); + else if (tab == Tab.Preview) + DrawPreviewTab(contentRect, inRect.height); + + settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; + settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; + + DrawSettingsButtons(new Rect(0, inRect.height - 40f, inRect.width, 35f)); + } + + private void DrawPreviewTab(Rect contentRect, float windowHeight) + { + RebuildTomlPreview(); + var previewRect = new Rect(contentRect.x, contentRect.y, contentRect.width, windowHeight - contentRect.y - 50f); + DrawTomlPreview(previewRect); + } + + private void DoTabButton(Rect r, Tab tab) + { + Widgets.DrawOptionBackground(r, tab == this.tab); + if (Widgets.ButtonInvisible(r, true)) + { + this.tab = tab; + SoundDefOf.Click.PlayOneShotOnCamera(); + } + + float x = r.x + 10f; + Texture2D icon; + string label; + + if (tab == Tab.Connecting) + { + icon = ContentFinder.Get("UI/Icons/Options/OptionsGeneral"); + label = "MpHostTabConnecting".Translate(); + } + else if (tab == Tab.Gameplay) + { + icon = ContentFinder.Get("UI/Icons/Options/OptionsGameplay"); + label = "MpHostTabGameplay".Translate(); + } + else + { + icon = null; + label = "Preview"; + } + + if (icon != null) + { + var iconRect = new Rect(x, r.y + (r.height - 20f) / 2f, 20f, 20f); + GUI.DrawTexture(iconRect, icon); + x += 30f; + } + + Widgets.Label(new Rect(x, r.y, r.width - x, r.height), label); + } + + private void DrawSettingsButtons(Rect inRect) + { + Rect nextRect; + if (Prefs.DevMode) + { + var copyRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); + if (Widgets.ButtonText(copyRect, "Copy TOML")) + { + RebuildTomlPreview(); + GUIUtility.systemCopyBuffer = tomlPreview; + Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); + } + + nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); + } + else + { + nextRect = new Rect((inRect.width - 150f) / 2f, inRect.y, 150f, inRect.height); + } + + var nextLabel = settingsUploaded ? "Uploaded" : "Next"; + var nextEnabled = !isUploadingToml && !settingsUploaded; + + var prevColor = GUI.color; + if (!nextEnabled) + GUI.color = new Color(1f, 1f, 1f, 0.5f); + + if (Widgets.ButtonText(nextRect, nextLabel) && nextEnabled) + { + RebuildTomlPreview(); + StartUploadSettingsToml(); + } + + GUI.color = prevColor; + } + + private void StartUploadSettingsToml() + { + isUploadingToml = true; + uploadProgress = 0f; + statusText = "Uploading server settings..."; + + new System.Threading.Thread(() => + { + try + { + connection.Send(new ClientBootstrapSettingsPacket(settings)); + + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + settingsUploaded = true; + statusText = "Server settings configured correctly. Proceed with map generation."; + step = Step.GenerateMap; + + if (Multiplayer.session != null) + Multiplayer.session.serverBootstrapSettingsMissing = false; + }); + } + catch (Exception e) + { + Log.Error($"Bootstrap settings upload failed: {e}"); + OnMainThread.Enqueue(() => + { + isUploadingToml = false; + statusText = $"Failed to upload settings: {e.GetType().Name}: {e.Message}"; + }); + } + }) { IsBackground = true, Name = "MP Bootstrap settings upload" }.Start(); + } + + private void DrawTomlPreview(Rect inRect) + { + Widgets.DrawMenuSection(inRect); + var inner = inRect.ContractedBy(10f); + + Text.Font = GameFont.Small; + Widgets.Label(inner.TopPartPixels(22f), "settings.toml preview"); + + var previewRect = new Rect(inner.x, inner.y + 26f, inner.width, inner.height - 26f); + var content = tomlPreview ?? string.Empty; + var contentHeight = Text.CalcHeight(content, previewRect.width - 16f) + 20f; + var viewRect = new Rect(0f, 0f, previewRect.width - 16f, Mathf.Max(previewRect.height, contentHeight)); + + Widgets.BeginScrollView(previewRect, ref tomlScroll, viewRect); + Widgets.Label(new Rect(0f, 0f, viewRect.width, viewRect.height), content); + Widgets.EndScrollView(); + } + + private void RebuildTomlPreview() + { + tomlPreview = "# Generated by Multiplayer bootstrap configurator\n" + + "# Serialized with TomlSettingsCommon\n\n" + + TomlSettingsCommon.Serialize(settings); + } + } +} \ No newline at end of file diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index dba806681..628b79d53 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -1,20 +1,10 @@ using System; using System.IO; -using System.Linq; -using System.Text; -using System.Security.Cryptography; -using LiteNetLib; -using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; -using Multiplayer.Common.Networking.Packet; -using Multiplayer.Common.Util; -using RimWorld; -using RimWorld.Planet; using UnityEngine; using Verse; -using Verse.Sound; namespace Multiplayer.Client { @@ -22,7 +12,7 @@ namespace Multiplayer.Client /// Shown when connecting to a server that's in bootstrap/configuration mode. /// Guides the user through uploading settings.toml (if needed) and then save.zip. /// - public class BootstrapConfiguratorWindow : Window + public partial class BootstrapConfiguratorWindow : Window { private readonly ConnectionBase connection; private string serverAddress; @@ -180,489 +170,6 @@ public override void DoWindowContents(Rect inRect) DrawGenerateMap(entry, inRect); } - private void DrawGenerateMap(Rect entry, Rect inRect) - { - // Status text - Text.Font = GameFont.Small; - var statusHeight = Text.CalcHeight(statusText ?? "", entry.width); - Widgets.Label(entry.Height(statusHeight), statusText ?? ""); - entry = entry.Down(statusHeight + 10); - - // Important notice about faction ownership - if (!AwaitingBootstrapMapInit && !saveReady && !isUploadingSave && !isReconnecting) - { - var noticeRect = entry.Height(100f); - GUI.color = new Color(1f, 0.85f, 0.5f); - Widgets.DrawBoxSolid(noticeRect, new Color(0.3f, 0.25f, 0.1f, 0.5f)); - GUI.color = Color.white; - - var noticeTextRect = noticeRect.ContractedBy(8f); - Text.Font = GameFont.Tiny; - GUI.color = new Color(1f, 0.9f, 0.6f); - Widgets.Label(noticeTextRect, - "IMPORTANT: The user who generates this map will own the main faction (colony).\n" + - "When setting up the server, make sure this user's username is listed as the host.\n" + - "Other players connecting to the server will be assigned as spectators or secondary factions."); - GUI.color = Color.white; - Text.Font = GameFont.Small; - entry = entry.Down(110); - } - - // Save upload status - if (!string.IsNullOrEmpty(saveUploadStatus)) - { - var saveStatusHeight = Text.CalcHeight(saveUploadStatus, entry.width); - Widgets.Label(entry.Height(saveStatusHeight), saveUploadStatus); - entry = entry.Down(saveStatusHeight + 4); - } - - // Progress bar - if (autoAdvanceArmed || isUploadingSave) - { - var barRect = entry.Height(18f); - Widgets.FillableBar(barRect, isUploadingSave ? saveUploadProgress : 0.1f); - entry = entry.Down(24); - } - - // Generate map button - bool showGenerateButton = !(autoAdvanceArmed || AwaitingBootstrapMapInit || saveReady || isUploadingSave || isReconnecting); - if (showGenerateButton) - { - var buttonRect = new Rect((inRect.width - 200f) / 2f, inRect.height - 45f, 200f, 40f); - if (Widgets.ButtonText(buttonRect, "Generate map")) - { - saveUploadAutoStarted = false; - hideWindowDuringMapGen = true; - StartVanillaNewColonyFlow(); - } - } - - // Auto-start upload when save is ready - if (saveReady && !isUploadingSave && !saveUploadAutoStarted && Multiplayer.Client == null && Current.ProgramState != ProgramState.Playing) - { - saveUploadAutoStarted = true; - ReconnectAndUploadSave(); - } - } - - private void DrawSettings(Rect entry, Rect inRect) - { - // Status + progress - if (!string.IsNullOrEmpty(statusText)) - { - var statusHeight = Text.CalcHeight(statusText, entry.width); - Widgets.Label(entry.Height(statusHeight), statusText); - entry = entry.Down(statusHeight + 4); - } - - if (isUploadingToml) - { - var barRect = entry.Height(20f); - Widgets.FillableBar(barRect, uploadProgress); - entry = entry.Down(24); - } - - // Tab buttons - using (MpStyle.Set(TextAnchor.MiddleLeft)) - { - DoTabButton(entry.Width(140).Height(40f), Tab.Connecting); - DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); - if (Prefs.DevMode) - DoTabButton(entry.Down(100f).Width(140).Height(40f), Tab.Preview); - } - - // Content based on selected tab - var contentRect = entry.MinX(entry.xMin + 150); - var buffers = new ServerSettingsUI.BufferSet - { - MaxPlayersBuffer = settingsUiBuffers.MaxPlayersBuffer, - AutosaveBuffer = settingsUiBuffers.AutosaveBuffer - }; - - if (tab == Tab.Connecting) - ServerSettingsUI.DrawNetworkingSettings(contentRect, settings, buffers); - else if (tab == Tab.Gameplay) - ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); - else if (tab == Tab.Preview) - { - RebuildTomlPreview(); - var previewRect = new Rect(contentRect.x, contentRect.y, contentRect.width, inRect.height - contentRect.y - 50f); - DrawTomlPreview(previewRect); - } - - // Sync buffers back - settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; - settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; - - // Buttons at bottom - DrawSettingsButtons(new Rect(0, inRect.height - 40f, inRect.width, 35f)); - } - - private void DoTabButton(Rect r, Tab tab) - { - Widgets.DrawOptionBackground(r, tab == this.tab); - if (Widgets.ButtonInvisible(r, true)) - { - this.tab = tab; - SoundDefOf.Click.PlayOneShotOnCamera(); - } - - float num = r.x + 10f; - Texture2D icon; - string label; - - if (tab == Tab.Connecting) - { - icon = ContentFinder.Get("UI/Icons/Options/OptionsGeneral"); - label = "MpHostTabConnecting".Translate(); - } - else if (tab == Tab.Gameplay) - { - icon = ContentFinder.Get("UI/Icons/Options/OptionsGameplay"); - label = "MpHostTabGameplay".Translate(); - } - else - { - icon = null; - label = "Preview"; - } - - if (icon != null) - { - Rect rect = new Rect(num, r.y + (r.height - 20f) / 2f, 20f, 20f); - GUI.DrawTexture(rect, icon); - num += 30f; - } - - Widgets.Label(new Rect(num, r.y, r.width - num, r.height), label); - } - - private void DrawSettingsButtons(Rect inRect) - { - Rect nextRect; - if (Prefs.DevMode) - { - var copyRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); - if (Widgets.ButtonText(copyRect, "Copy TOML")) - { - RebuildTomlPreview(); - GUIUtility.systemCopyBuffer = tomlPreview; - Messages.Message("Copied settings.toml to clipboard", MessageTypeDefOf.SilentInput, false); - } - nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); - } - else - { - nextRect = new Rect((inRect.width - 150f) / 2f, inRect.y, 150f, inRect.height); - } - - var nextLabel = settingsUploaded ? "Uploaded" : "Next"; - var nextEnabled = !isUploadingToml && !settingsUploaded; - - var prevColor = GUI.color; - if (!nextEnabled) - GUI.color = new Color(1f, 1f, 1f, 0.5f); - - if (Widgets.ButtonText(nextRect, nextLabel)) - { - if (nextEnabled) - { - RebuildTomlPreview(); - StartUploadSettingsToml(); - } - } - - GUI.color = prevColor; - } - - private void StartUploadSettingsToml() - { - isUploadingToml = true; - uploadProgress = 0f; - statusText = "Uploading server settings..."; - - new System.Threading.Thread(() => - { - try - { - connection.Send(new ClientBootstrapSettingsPacket(settings)); - - OnMainThread.Enqueue(() => - { - isUploadingToml = false; - settingsUploaded = true; - statusText = "Server settings configured correctly. Proceed with map generation."; - step = Step.GenerateMap; - - // Clear the flag so WindowUpdate() doesn't reset step back to Settings - if (Multiplayer.session != null) - Multiplayer.session.serverBootstrapSettingsMissing = false; - }); - } - catch (Exception e) - { - Log.Error($"Bootstrap settings upload failed: {e}"); - OnMainThread.Enqueue(() => - { - isUploadingToml = false; - statusText = $"Failed to upload settings: {e.GetType().Name}: {e.Message}"; - }); - } - }) { IsBackground = true, Name = "MP Bootstrap settings upload" }.Start(); - } - - private void StartVanillaNewColonyFlow() - { - // Disconnect from server before world generation to avoid sync conflicts. - // We'll reconnect after the autosave is complete to upload save.zip. - if (Multiplayer.session != null) - { - Multiplayer.session.Stop(); - Multiplayer.session = null; - } - - try - { - Current.Game ??= new Game(); - Current.Game.InitData ??= new GameInitData { startedFromEntry = true }; - - // Ensure BootstrapCoordinator is added to the game components - if (Current.Game.components.All(c => c is not BootstrapCoordinator)) - { - Current.Game.components.Add(new BootstrapCoordinator(Current.Game)); - } - - var scenarioPage = new Page_SelectScenario(); - Find.WindowStack.Add(PageUtility.StitchedPages(new System.Collections.Generic.List { scenarioPage })); - - // Start watching for page flow + map entry - saveReady = false; - savedReplayPath = null; - autoAdvanceArmed = true; - AwaitingBootstrapMapInit = true; - saveUploadStatus = "Generating map..."; - } - catch (Exception e) - { - Messages.Message($"Failed to start New Colony flow: {e.GetType().Name}: {e.Message}", MessageTypeDefOf.RejectInput, false); - } - } - - private void TryArmAwaitingBootstrapMapInit() - { - if (AwaitingBootstrapMapInit) - return; - - // Once a local hosted MP session exists, bootstrap map-init detection must stop. - if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting || saveUploadAutoStarted) - return; - - try - { - if (LongEventHandler.AnyEventNowOrWaiting) - return; - } - catch - { - // If the API isn't available, fail open. - } - - if (Current.ProgramState != ProgramState.Playing) - return; - - if (Find.Maps == null || Find.Maps.Count == 0) - return; - - AwaitingBootstrapMapInit = true; - saveUploadStatus = "Entered map. Waiting for initialization to complete..."; - - // Stop page driver - autoAdvanceArmed = false; - - // Re-add to WindowStack if missing (Root_Play transition clears all windows) - if (Find.WindowStack != null && Find.WindowStack.WindowOfType() == null) - Find.WindowStack.Add(this); - } - - internal void TryArmAwaitingBootstrapMapInit_FromRootPlay() - { - TryArmAwaitingBootstrapMapInit(); - } - - internal void TryArmAwaitingBootstrapMapInit_FromRootPlayUpdate() - { - TryArmAwaitingBootstrapMapInit(); - - // Also drive the post-map save pipeline from this reliable update loop - TickPostMapEnterSaveDelayAndMaybeSave(); - } - - public void OnBootstrapMapInitialized() - { - if (!AwaitingBootstrapMapInit) - return; - - hideWindowDuringMapGen = false; - - AwaitingBootstrapMapInit = false; - postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; - awaitingControllablePawns = true; - awaitingControllablePawnsElapsed = 0f; - bootstrapSaveQueued = false; - saveUploadStatus = "Map initialized. Waiting before saving..."; - } - - private void TickPostMapEnterSaveDelayAndMaybeSave() - { - if (bootstrapSaveQueued || saveReady || isUploadingSave || isReconnecting) - return; - - // Skip if timer was never started (default 0) AND we're not waiting for pawns - if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns) - return; - - postMapEnterSaveDelayRemaining -= Time.deltaTime; - - if (postMapEnterSaveDelayRemaining > 0f) - return; - - // Wait until we actually have spawned pawns - if (awaitingControllablePawns) - { - awaitingControllablePawnsElapsed += Time.deltaTime; - - if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap != null) - { - var anyColonist = false; - try - { - anyColonist = Find.CurrentMap.mapPawns?.FreeColonistsSpawned != null && - Find.CurrentMap.mapPawns.FreeColonistsSpawned.Count > 0; - } - catch (Exception ex) - { - Log.Error($"Exception checking for controllable colonists: {ex.GetType().Name}: {ex.Message}"); - } - - if (anyColonist) - { - awaitingControllablePawns = false; - try { Find.TickManager.CurTimeSpeed = TimeSpeed.Paused; } catch { } - } - } - - if (awaitingControllablePawns) - { - if (awaitingControllablePawnsElapsed > AwaitControllablePawnsTimeoutSeconds) - { - awaitingControllablePawns = false; - Log.Warning("Timed out waiting for controllable pawns during bootstrap; saving anyway"); - } - else - { - saveUploadStatus = "Waiting for controllable colonists to spawn..."; - return; - } - } - } - - postMapEnterSaveDelayRemaining = 0f; - bootstrapSaveQueued = true; - saveUploadStatus = "Map initialized. Starting hosted MP session..."; - - LongEventHandler.QueueLongEvent(() => - { - try - { - var hostSettings = new ServerSettings - { - gameName = settings.gameName, - maxPlayers = 2, - direct = false, - lan = false, - steam = false, - }; - - bool hosted = HostWindow.HostProgrammatically(hostSettings); - if (!hosted) - { - OnMainThread.Enqueue(() => - { - saveUploadStatus = "Failed to host MP session."; - Log.Error("HostProgrammatically failed during bootstrap save creation"); - bootstrapSaveQueued = false; - }); - return; - } - - OnMainThread.Enqueue(() => - { - saveUploadStatus = "Hosted. Saving replay..."; - - LongEventHandler.QueueLongEvent(() => - { - try - { - Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); - - var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); - - OnMainThread.Enqueue(() => - { - savedReplayPath = path; - saveReady = File.Exists(savedReplayPath); - lastSavedReplayPath = savedReplayPath; - lastSaveReady = saveReady; - - if (saveReady) - { - // Prevent DrawGenerateMap from reconnecting before we've returned to the menu. - saveUploadAutoStarted = true; - saveUploadStatus = "Save created. Returning to menu..."; - - LongEventHandler.QueueLongEvent(() => - { - GenScene.GoToMainMenu(); - - OnMainThread.Enqueue(() => - { - saveUploadStatus = "Reconnecting to upload save..."; - ReconnectAndUploadSave(); - }); - }, "Returning to menu", false, null); - } - else - { - saveUploadStatus = $"Save finished but file not found: {path}"; - Log.Error($"Bootstrap save finished but file was missing: {savedReplayPath}"); - bootstrapSaveQueued = false; - } - }); - } - catch (Exception e) - { - OnMainThread.Enqueue(() => - { - saveUploadStatus = $"Save failed: {e.GetType().Name}: {e.Message}"; - Log.Error($"Bootstrap save failed: {e}"); - bootstrapSaveQueued = false; - }); - } - }, "Saving", false, null); - }); - } - catch (Exception e) - { - OnMainThread.Enqueue(() => - { - saveUploadStatus = $"Host failed: {e.GetType().Name}: {e.Message}"; - Log.Error($"Bootstrap host exception: {e}"); - bootstrapSaveQueued = false; - }); - } - }, "Starting host", false, null); - } - public override void PreOpen() { base.PreOpen(); @@ -725,218 +232,5 @@ internal void BootstrapCoordinatorTick() TickPostMapEnterSaveDelayAndMaybeSave(); } - private void ReconnectAndUploadSave() - { - saveUploadStatus = "Reconnecting to server..."; - - try - { - Multiplayer.StopMultiplayer(); - - Multiplayer.session = new MultiplayerSession - { - address = serverAddress, - port = serverPort - }; - - var netClient = new NetManager(new MpClientNetListener()) - { - EnableStatistics = true, - IPv6Enabled = MpUtil.SupportsIPv6() ? IPv6Mode.SeparateSocket : IPv6Mode.Disabled - }; - netClient.Start(); - netClient.ReconnectDelay = 300; - netClient.MaxConnectAttempts = 8; - - Multiplayer.session.netClient = netClient; - netClient.Connect(serverAddress, serverPort, ""); - - isReconnecting = true; - reconnectCheckTimer = 0; - } - catch (Exception e) - { - saveUploadStatus = $"Reconnection failed: {e.GetType().Name}: {e.Message}"; - isUploadingSave = false; - } - } - - private void CheckReconnectionState() - { - reconnectCheckTimer++; - - if (Multiplayer.Client?.State == ConnectionStateEnum.ClientBootstrap) - { - saveUploadStatus = "Reconnected. Starting upload..."; - isReconnecting = false; - reconnectCheckTimer = 0; - StartUploadSaveZip(); - } - else if (Multiplayer.Client?.State == ConnectionStateEnum.Disconnected || (Multiplayer.Client == null && reconnectCheckTimer > 300)) - { - saveUploadStatus = "Reconnection failed. Cannot upload save.zip."; - isReconnecting = false; - reconnectCheckTimer = 0; - isUploadingSave = false; - } - else if (reconnectCheckTimer > 600) // ~10 seconds at 60fps - { - saveUploadStatus = "Reconnection timeout. Cannot upload save.zip."; - isReconnecting = false; - reconnectCheckTimer = 0; - isUploadingSave = false; - } - } - - private void StartUploadSaveZip() - { - if (string.IsNullOrWhiteSpace(savedReplayPath) || !File.Exists(savedReplayPath)) - { - saveUploadStatus = "Can't upload: autosave file not found."; - return; - } - - isUploadingSave = true; - saveUploadProgress = 0f; - saveUploadStatus = "Uploading save.zip..."; - - byte[] bytes; - try - { - bytes = File.ReadAllBytes(savedReplayPath); - } - catch (Exception e) - { - isUploadingSave = false; - saveUploadStatus = $"Failed to read autosave: {e.GetType().Name}: {e.Message}"; - return; - } - - var targetConn = Multiplayer.Client; - if (targetConn == null) - { - isUploadingSave = false; - saveUploadStatus = "No active connection. Cannot upload."; - return; - } - - new System.Threading.Thread(() => - { - try - { - targetConn.Send(new ClientBootstrapSaveStartPacket("save.zip", bytes.Length)); - - const int chunk = 256 * 1024; - var sent = 0; - while (sent < bytes.Length) - { - var len = Math.Min(chunk, bytes.Length - sent); - var part = new byte[len]; - Buffer.BlockCopy(bytes, sent, part, 0, len); - targetConn.SendFragmented(new ClientBootstrapSaveDataPacket(part).Serialize()); - sent += len; - var progress = bytes.Length == 0 ? 1f : (float)sent / bytes.Length; - OnMainThread.Enqueue(() => saveUploadProgress = Mathf.Clamp01(progress)); - } - - byte[] sha256Hash; - using (var hasher = SHA256.Create()) - sha256Hash = hasher.ComputeHash(bytes); - - targetConn.Send(new ClientBootstrapSaveEndPacket(sha256Hash)); - - OnMainThread.Enqueue(() => - { - saveUploadStatus = "Upload finished. Waiting for server to confirm and shut down..."; - }); - } - catch (Exception e) - { - OnMainThread.Enqueue(() => - { - isUploadingSave = false; - saveUploadStatus = $"Failed to upload save.zip: {e.GetType().Name}: {e.Message}"; - }); - } - }) { IsBackground = true, Name = "MP Bootstrap save upload" }.Start(); - } - - private void DrawTomlPreview(Rect inRect) - { - Widgets.DrawMenuSection(inRect); - var inner = inRect.ContractedBy(10f); - - Text.Font = GameFont.Small; - Widgets.Label(inner.TopPartPixels(22f), "settings.toml preview"); - - var previewRect = new Rect(inner.x, inner.y + 26f, inner.width, inner.height - 26f); - var content = tomlPreview ?? ""; - - var viewRect = new Rect(0f, 0f, previewRect.width - 16f, Mathf.Max(previewRect.height, Text.CalcHeight(content, previewRect.width - 16f) + 20f)); - Widgets.BeginScrollView(previewRect, ref tomlScroll, viewRect); - Widgets.Label(new Rect(0f, 0f, viewRect.width, viewRect.height), content); - Widgets.EndScrollView(); - } - - private void RebuildTomlPreview() - { - var sb = new StringBuilder(); - - sb.AppendLine("# Generated by Multiplayer bootstrap configurator"); - sb.AppendLine("# Keys must match ServerSettings.ExposeData()\n"); - - AppendKv(sb, "directAddress", settings.directAddress); - AppendKv(sb, "maxPlayers", settings.maxPlayers); - AppendKv(sb, "autosaveInterval", settings.autosaveInterval); - AppendKv(sb, "autosaveUnit", settings.autosaveUnit.ToString()); - AppendKv(sb, "steam", settings.steam); - AppendKv(sb, "direct", settings.direct); - AppendKv(sb, "lan", settings.lan); - AppendKv(sb, "asyncTime", settings.asyncTime); - AppendKv(sb, "multifaction", settings.multifaction); - AppendKv(sb, "debugMode", settings.debugMode); - AppendKv(sb, "desyncTraces", settings.desyncTraces); - AppendKv(sb, "syncConfigs", settings.syncConfigs); - AppendKv(sb, "autoJoinPoint", settings.autoJoinPoint.ToString()); - AppendKv(sb, "devModeScope", settings.devModeScope.ToString()); - AppendKv(sb, "hasPassword", settings.hasPassword); - AppendKv(sb, "password", settings.password ?? ""); - AppendKv(sb, "pauseOnLetter", settings.pauseOnLetter.ToString()); - AppendKv(sb, "pauseOnJoin", settings.pauseOnJoin); - AppendKv(sb, "pauseOnDesync", settings.pauseOnDesync); - AppendKv(sb, "timeControl", settings.timeControl.ToString()); - - tomlPreview = sb.ToString(); - } - - private static void AppendKv(StringBuilder sb, string key, string value) - { - sb.Append(key); - sb.Append(" = "); - var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); - sb.Append('"').Append(escaped).Append('"'); - sb.AppendLine(); - } - - private static void AppendKv(StringBuilder sb, string key, bool value) - { - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value ? "true" : "false"); - } - - private static void AppendKv(StringBuilder sb, string key, int value) - { - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value.ToString()); - } - - private static void AppendKv(StringBuilder sb, string key, float value) - { - sb.Append(key); - sb.Append(" = "); - sb.AppendLine(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); - } } }