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/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/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 216f588b7..38e02c842 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -4,12 +4,13 @@ namespace Multiplayer.Client; +[PacketHandlerClass] public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionState(connection) { 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 new file mode 100644 index 000000000..d86e4a98e --- /dev/null +++ b/Source/Client/Networking/State/ClientBootstrapState.cs @@ -0,0 +1,45 @@ +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] +public class ClientBootstrapState(ConnectionBase connection) : ClientBaseState(connection) +{ + [TypedPacketHandler] + 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) + { + 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/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/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.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 new file mode 100644 index 000000000..628b79d53 --- /dev/null +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -0,0 +1,236 @@ +using System; +using System.IO; +using Multiplayer.Client.Networking; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using UnityEngine; +using Verse; + +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 partial 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; + + // 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; + + // 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; + + // 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)) + { + 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); + } + + 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(); + + TickPostMapEnterSaveDelayAndMaybeSave(); + } + + } +} 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..0ecbde623 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 @@ -180,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/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) 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))