From aed14ed431aa7d0024810c99e7df1e277ebedbf7 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:20:53 +1030 Subject: [PATCH 1/7] Add disconnect handling from main branch (ReplyPool.cancelAll, RemoteClient slot tracking) Port changes from NetworkPlay/main that prevent server deadlock on client disconnect: - ReplyPool.cancelAll(): completes all pending futures with null - RemoteClient.cancelPendingReplies(): calls cancelAll on disconnect - RemoteClient.UNASSIGNED_SLOT / hasValidSlot(): slot assignment tracking These are correctness fixes for the existing disconnect path, not reconnection-specific. Already in use on main via DeregisterClientHandler.channelInactive(). Co-Authored-By: Claude Opus 4.6 --- .../main/java/forge/gamemodes/net/ReplyPool.java | 14 ++++++++++++++ .../forge/gamemodes/net/server/RemoteClient.java | 13 ++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ReplyPool.java b/forge-gui/src/main/java/forge/gamemodes/net/ReplyPool.java index 30c63223d09..87b6f84fc29 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ReplyPool.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ReplyPool.java @@ -39,6 +39,20 @@ public Object get(final int index) throws TimeoutException { } } + /** + * Cancel all pending replies by completing them with null. + * This is used when a player is converted to AI to unblock any waiting game threads. + */ + public void cancelAll() { + synchronized (pool) { + for (CompletableFuture future : pool.values()) { + // Complete with null to unblock waiting threads + future.set(null); + } + pool.clear(); + } + } + private static final class CompletableFuture extends FutureTask { public CompletableFuture() { super(() -> null); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java index 407169eaba7..b1111fd9f94 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java @@ -9,14 +9,25 @@ public final class RemoteClient implements IToClient { + /** Special value indicating the client hasn't been assigned a slot yet. */ + public static final int UNASSIGNED_SLOT = -1; + private final Channel channel; private String username; - private int index; + private int index = UNASSIGNED_SLOT; // Initialize to -1 to indicate not yet assigned private ReplyPool replies = new ReplyPool(); public RemoteClient(final Channel channel) { this.channel = channel; } + /** + * Check if this client has been assigned a valid lobby slot. + * @return true if the client has a valid slot (index >= 0) + */ + public boolean hasValidSlot() { + return index >= 0; + } + @Override public void send(final NetEvent event) { System.out.println("Sending event " + event + " to " + channel); From 5460cb83192a1b4218f92c85a60c97a68bc4528b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:10:35 +1030 Subject: [PATCH 2/7] Add server-side reconnect handling (pause on disconnect, resume or AI takeover) When a remote client disconnects mid-game, the server now pauses the game and waits up to 120 seconds for the player to rejoin. If the same username reconnects, the channel is swapped on the original RemoteClient, game state is resynced, and the current prompt is replayed. If the timeout expires, the player is converted to AI and the game continues. Key changes: - RemoteClient: volatile channel/replies, swapChannel() for reconnection - NetGuiGame: pause/resume (sends become no-ops), null-safe primitive returns to prevent NPE from cancelAll()/timeout null values - FServerManager: reconnection flow in DeregisterClientHandler/LobbyInputHandler, helper methods for pause/resume/resync/AI conversion, ConcurrentHashMap for thread-safe client tracking - GameLobby: getHostedMatch() accessor for server reconnection logic Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + .../java/forge/gamemodes/match/GameLobby.java | 4 + .../gamemodes/net/server/FServerManager.java | 217 +++++++++++++++++- .../gamemodes/net/server/NetGuiGame.java | 28 ++- .../gamemodes/net/server/RemoteClient.java | 24 +- 5 files changed, 260 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index eb48c74dba1..d7f7ce8ce28 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,7 @@ forge-gui/tools/PerSetTrackingResults # Ignore python temporaries __pycache__ *.pyc + +# Ignore Claude configuration +.claude +CLAUDE.md \ No newline at end of file diff --git a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java index 80044e03ab9..5d960a2d2f8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java @@ -55,6 +55,10 @@ public final boolean isMatchActive() { return hostedMatch != null && hostedMatch.isMatchOver() == false; } + public HostedMatch getHostedMatch() { + return hostedMatch; + } + public void setListener(final IUpdateable listener) { this.listener = listener; } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index ea4852fc088..52ff284a298 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -1,7 +1,11 @@ package forge.gamemodes.net.server; import com.google.common.collect.ImmutableList; -import com.google.common.collect.Maps; +import forge.ai.LobbyPlayerAi; +import forge.ai.PlayerControllerAi; +import forge.game.Game; +import forge.game.player.Player; +import forge.gamemodes.match.HostedMatch; import forge.gamemodes.match.LobbySlot; import forge.gamemodes.match.LobbySlotType; import forge.gamemodes.net.CompatibleObjectDecoder; @@ -13,6 +17,7 @@ import forge.interfaces.IGameController; import forge.interfaces.ILobbyListener; import forge.model.FModel; +import forge.player.PlayerControllerHuman; import forge.util.IterableUtil; import forge.util.Localizer; import forge.localinstance.properties.ForgeNetPreferences; @@ -36,11 +41,16 @@ import java.io.InputStreamReader; import java.net.*; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; public final class FServerManager { + private static final int RECONNECT_TIMEOUT_SECONDS = 120; + private static FServerManager instance = null; - private final Map clients = Maps.newTreeMap(); + private final Map clients = new ConcurrentHashMap<>(); + private final Map disconnectedClients = new ConcurrentHashMap<>(); + private final Map reconnectTimers = new ConcurrentHashMap<>(); private boolean isHosting = false; private EventLoopGroup bossGroup = new NioEventLoopGroup(1); private EventLoopGroup workerGroup = new NioEventLoopGroup(); @@ -161,6 +171,13 @@ public void stopServer() { } private void stopServer(final boolean removeShutdownHook) { + // Cancel all reconnect timers + for (final Timer timer : reconnectTimers.values()) { + timer.cancel(); + } + reconnectTimers.clear(); + disconnectedClients.clear(); + bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); if (upnpService != null) { @@ -334,6 +351,122 @@ private void mapNatPort() { } } + // --- Reconnection helper methods --- + + private PlayerControllerHuman findRemoteController(final int slotIndex) { + final IGameController controller = localLobby.getController(slotIndex); + if (controller instanceof PlayerControllerHuman) { + return (PlayerControllerHuman) controller; + } + return null; + } + + private void pauseNetGuiGame(final int slotIndex) { + final HostedMatch hostedMatch = localLobby.getHostedMatch(); + if (hostedMatch == null) { return; } + final Game game = hostedMatch.getGame(); + if (game == null) { return; } + + for (final Player p : game.getPlayers()) { + if (p.getName().equals(localLobby.getSlot(slotIndex).getName())) { + final IGuiGame gui = findGuiForPlayer(p); + if (gui instanceof NetGuiGame) { + ((NetGuiGame) gui).pause(); + } + return; + } + } + } + + private IGuiGame findGuiForPlayer(final Player player) { + final HostedMatch hostedMatch = localLobby.getHostedMatch(); + if (hostedMatch == null) { return null; } + // The NetGuiGame is stored per RegisteredPlayer in HostedMatch's guis map, + // but we don't have direct access. Look up via the slot-based getGui. + for (int i = 0; i < localLobby.getNumberOfSlots(); i++) { + final LobbySlot slot = localLobby.getSlot(i); + if (slot != null && player.getName().equals(slot.getName())) { + // For REMOTE slots, getGui creates a new NetGuiGame wrapper each time. + // We need the actual one in use. Find it via the player's controller. + if (player.getController() instanceof PlayerControllerHuman) { + return ((PlayerControllerHuman) player.getController()).getGui(); + } + } + } + return null; + } + + private void resumeAndResync(final RemoteClient client) { + final int slotIndex = client.getIndex(); + final HostedMatch hostedMatch = localLobby.getHostedMatch(); + if (hostedMatch == null) { return; } + final Game game = hostedMatch.getGame(); + if (game == null) { return; } + + for (final Player p : game.getPlayers()) { + if (p.getName().equals(client.getUsername())) { + final IGuiGame gui = findGuiForPlayer(p); + if (gui instanceof NetGuiGame) { + final NetGuiGame netGui = (NetGuiGame) gui; + netGui.resume(); + + // Send full game state to the reconnected client + netGui.openView(new forge.trackable.TrackableCollection<>(netGui.getLocalPlayers())); + + // Replay current prompt + final PlayerControllerHuman pch = findRemoteController(slotIndex); + if (pch != null) { + pch.getInputQueue().updateObservers(); + } + } + return; + } + } + } + + private void handleReconnectTimeout(final String username) { + reconnectTimers.remove(username); + final RemoteClient client = disconnectedClients.remove(username); + if (client == null) { return; } + + // If match already ended, just clean up + if (!isMatchActive()) { + localLobby.disconnectPlayer(client.getIndex()); + return; + } + + System.out.println("Reconnect timeout for " + username + ". Converting to AI."); + convertToAI(client.getIndex(), username); + + // Reset lobby slot + localLobby.disconnectPlayer(client.getIndex()); + + broadcast(new MessageEvent(String.format("%s did not reconnect in time. AI has taken over.", username))); + } + + private void convertToAI(final int slotIndex, final String username) { + final HostedMatch hostedMatch = localLobby.getHostedMatch(); + if (hostedMatch == null) { return; } + final Game game = hostedMatch.getGame(); + if (game == null) { return; } + + for (final Player p : game.getPlayers()) { + if (p.getName().equals(username)) { + // Create AI controller using the player's existing LobbyPlayer + final LobbyPlayerAi aiLobbyPlayer = new LobbyPlayerAi(username, null); + final PlayerControllerAi aiCtrl = new PlayerControllerAi(game, p, aiLobbyPlayer); + p.dangerouslySetController(aiCtrl); + + // Clear InputQueue to unblock Path A (game thread waiting on cdlDone) + final PlayerControllerHuman pch = findRemoteController(slotIndex); + if (pch != null) { + pch.getInputQueue().clearInputs(); + } + return; + } + } + } + private class MessageHandler extends ChannelInboundHandlerAdapter { @Override public final void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception { @@ -376,13 +509,41 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw final RemoteClient client = clients.get(ctx.channel()); if (msg instanceof LoginEvent) { final LoginEvent event = (LoginEvent) msg; - final int index = localLobby.connectPlayer(event.getUsername(), event.getAvatarIndex(), event.getSleeveIndex()); - if (index == -1) { - ctx.close(); + final String username = event.getUsername(); + + // Check if this is a reconnecting player + final RemoteClient disconnected = disconnectedClients.remove(username); + if (disconnected != null) { + // Cancel timeout timer + final Timer timer = reconnectTimers.remove(username); + if (timer != null) { + timer.cancel(); + } + + // Remove the temporary client entry for the new channel + clients.remove(ctx.channel()); + + // Swap channel on the original RemoteClient + disconnected.swapChannel(ctx.channel()); + + // Re-register under the new channel + clients.put(ctx.channel(), disconnected); + + // Resume and resync + resumeAndResync(disconnected); + + broadcast(new MessageEvent(String.format("%s has reconnected", username))); + System.out.println("Player reconnected: " + username); } else { - client.setIndex(index); - broadcast(event); - updateLobbyState(); + // Normal login flow + final int index = localLobby.connectPlayer(event.getUsername(), event.getAvatarIndex(), event.getSleeveIndex()); + if (index == -1) { + ctx.close(); + } else { + client.setIndex(index); + broadcast(event); + updateLobbyState(); + } } } else if (msg instanceof UpdateLobbyPlayerEvent) { updateSlot(client.getIndex(), (UpdateLobbyPlayerEvent) msg); @@ -398,10 +559,44 @@ private class DeregisterClientHandler extends ChannelInboundHandlerAdapter { @Override public void channelInactive(final ChannelHandlerContext ctx) throws Exception { final RemoteClient client = clients.remove(ctx.channel()); + if (client == null) { + // Already handled (e.g. reconnect swapped the channel) + super.channelInactive(ctx); + return; + } final String username = client.getUsername(); - localLobby.disconnectPlayer(client.getIndex()); - broadcast(new MessageEvent(String.format("%s left the room", username))); - broadcast(new LogoutEvent(username)); + + if (isMatchActive() && client.hasValidSlot()) { + // Game is active — enter reconnection mode + client.setDisconnected(true); + + // Pause the NetGuiGame so sends become no-ops + pauseNetGuiGame(client.getIndex()); + + // Unblock any Path B waiter (sendAndWait) + client.getReplyPool().cancelAll(); + + // Store for reconnection lookup + disconnectedClients.put(username, client); + + // Start timeout timer + final Timer timer = new Timer("reconnect-timeout-" + username, true); + reconnectTimers.put(username, timer); + timer.schedule(new TimerTask() { + @Override + public void run() { + handleReconnectTimeout(username); + } + }, RECONNECT_TIMEOUT_SECONDS * 1000L); + + broadcast(new MessageEvent(String.format("%s disconnected. Waiting %d seconds for reconnect...", username, RECONNECT_TIMEOUT_SECONDS))); + System.out.println("Player disconnected mid-game: " + username + " (slot " + client.getIndex() + "). Waiting for reconnect."); + } else { + // Normal disconnect (lobby or no valid slot) + localLobby.disconnectPlayer(client.getIndex()); + broadcast(new MessageEvent(String.format("%s left the room", username))); + broadcast(new LogoutEvent(username)); + } super.channelInactive(ctx); } } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java index 0a0c7b54963..492d9a29864 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java @@ -30,15 +30,31 @@ public class NetGuiGame extends AbstractGuiGame { private final GameProtocolSender sender; + private volatile boolean paused; + public NetGuiGame(final IToClient client) { this.sender = new GameProtocolSender(client); } + public void pause() { + paused = true; + } + + public void resume() { + paused = false; + } + + public boolean isPaused() { + return paused; + } + private void send(final ProtocolMethod method, final Object... args) { + if (paused) { return; } sender.send(method, args); } private T sendAndWait(final ProtocolMethod method, final Object... args) { + if (paused) { return null; } return sender.sendAndWait(method, args); } @@ -227,12 +243,14 @@ public void showErrorDialog(final String message, final String title) { @Override public boolean showConfirmDialog(final String message, final String title, final String yesButtonText, final String noButtonText, final boolean defaultYes) { - return sendAndWait(ProtocolMethod.showConfirmDialog, message, title, yesButtonText, noButtonText, defaultYes); + final Boolean result = sendAndWait(ProtocolMethod.showConfirmDialog, message, title, yesButtonText, noButtonText, defaultYes); + return result != null ? result : defaultYes; } @Override public int showOptionDialog(final String message, final String title, final FSkinProp icon, final List options, final int defaultOption) { - return sendAndWait(ProtocolMethod.showOptionDialog, message, title, icon, options, defaultOption); + final Integer result = sendAndWait(ProtocolMethod.showOptionDialog, message, title, icon, options, defaultOption); + return result != null ? result : defaultOption; } @Override @@ -242,7 +260,8 @@ public String showInputDialog(final String message, final String title, final FS @Override public boolean confirm(final CardView c, final String question, final boolean defaultIsYes, final List options) { - return sendAndWait(ProtocolMethod.confirm, c, question, defaultIsYes, options); + final Boolean result = sendAndWait(ProtocolMethod.confirm, c, question, defaultIsYes, options); + return result != null ? result : defaultIsYes; } @Override @@ -311,7 +330,8 @@ public void restoreOldZones(PlayerView playerView, PlayerZoneUpdates playerZoneU @Override public boolean isUiSetToSkipPhase(final PlayerView playerTurn, final PhaseType phase) { - return sendAndWait(ProtocolMethod.isUiSetToSkipPhase, playerTurn, phase); + final Boolean result = sendAndWait(ProtocolMethod.isUiSetToSkipPhase, playerTurn, phase); + return Boolean.TRUE.equals(result); } @Override diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java index b1111fd9f94..09711e9beb0 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java @@ -12,14 +12,34 @@ public final class RemoteClient implements IToClient { /** Special value indicating the client hasn't been assigned a slot yet. */ public static final int UNASSIGNED_SLOT = -1; - private final Channel channel; + private volatile Channel channel; private String username; private int index = UNASSIGNED_SLOT; // Initialize to -1 to indicate not yet assigned - private ReplyPool replies = new ReplyPool(); + private volatile ReplyPool replies = new ReplyPool(); + private volatile boolean disconnected; + public RemoteClient(final Channel channel) { this.channel = channel; } + public boolean isDisconnected() { + return disconnected; + } + + public void setDisconnected(final boolean disconnected) { + this.disconnected = disconnected; + } + + /** + * Swap the underlying channel for a reconnecting client. + * Updates the channel, creates a fresh ReplyPool, and clears the disconnected flag. + */ + public void swapChannel(final Channel newChannel) { + this.channel = newChannel; + this.replies = new ReplyPool(); + this.disconnected = false; + } + /** * Check if this client has been assigned a valid lobby slot. * @return true if the client has a valid slot (index >= 0) From 2854b2c1c35a96a2ab6f839e361ae698078b0e01 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:45:31 +1030 Subject: [PATCH 3/7] Fix client reconnect: EDT timing race, same-name lookup, add diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameClientHandler: capture GameView synchronously in beforeCall(setGameView) as pendingGameView fallback for when gui.getGameView() is null during createMatch() (EDT hasn't processed setGameView yet when openView arrives) - FServerManager.resumeAndResync: send updateGameView() before openView() to match normal HostedMatch.startGame() message ordering - FServerManager: fix same-username loop in resumeAndResync/pauseNetGuiGame — continue searching when name matches but GUI is CMatchUI (host), not NetGuiGame - HostedMatch: add getGuiForPlayer() and dumpGuis() for authoritative GUI lookup - Add diagnostic logging to trace guis map contents during reconnect Co-Authored-By: Claude Opus 4.6 --- .../forge/gamemodes/match/HostedMatch.java | 18 +++++++++ .../net/client/GameClientHandler.java | 16 ++++++++ .../gamemodes/net/server/FServerManager.java | 38 +++++++++++++++---- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 85f6db690e3..45c6bbc3744 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -63,6 +63,24 @@ public class HostedMatch { public HostedMatch() {} + /** + * Look up the IGuiGame for a given Player from the guis map. + * This is the authoritative source for the GUI assigned to each player, + * unlike PlayerControllerHuman.getGui() which may be overwritten. + */ + public IGuiGame getGuiForPlayer(final Player player) { + if (guis == null || player == null) { return null; } + return guis.get(player.getRegisteredPlayer()); + } + + public void dumpGuis() { + if (guis == null) { System.out.println("[dumpGuis] guis is null"); return; } + System.out.println("[dumpGuis] guis map has " + guis.size() + " entries:"); + for (final Map.Entry e : guis.entrySet()) { + System.out.println("[dumpGuis] key=" + System.identityHashCode(e.getKey()) + " name=" + e.getKey().getPlayer().getName() + " -> " + (e.getValue() == null ? "null" : e.getValue().getClass().getSimpleName())); + } + } + public void setStartGameHook(Runnable hook) { startGameHook = hook; } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index 211c4169e77..2a609a2342b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java @@ -35,6 +35,7 @@ final class GameClientHandler extends GameProtocolHandler { private Tracker tracker; private Match match; private Game game; + private GameView pendingGameView; /** * Creates a client-side game handler. @@ -67,6 +68,14 @@ protected IGuiGame getToInvoke(final ChannelHandlerContext ctx) { @Override protected void beforeCall(final ProtocolMethod protocolMethod, final Object[] args) { switch (protocolMethod) { + case setGameView: + // Capture the GameView synchronously on the IO thread. + // The actual gui.setGameView() runs on EDT (queued by channelRead), + // so gui.getGameView() may still be null when openView arrives next. + if (args.length > 0 && args[0] instanceof GameView) { + this.pendingGameView = (GameView) args[0]; + } + break; case openView: // only need one **match** if (this.match == null) { @@ -142,6 +151,13 @@ private Match createMatch() { final IGuiGame gui = client.getGui(); GameView gameView = gui.getGameView(); + // gui.getGameView() may be null because setGameView was queued to EDT + // but hasn't executed yet. Fall back to the GameView captured synchronously + // in beforeCall when the setGameView event arrived. + if (gameView == null) { + gameView = this.pendingGameView; + } + final GameType gameType = getGameType(); final GameRules gameRules = createGameRules(gameType, gameView); final List registeredPlayers = createRegisteredPlayers(gameType); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index 52ff284a298..a277b9ee7e5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -45,7 +45,7 @@ import java.util.function.Predicate; public final class FServerManager { - private static final int RECONNECT_TIMEOUT_SECONDS = 120; + private static final int RECONNECT_TIMEOUT_SECONDS = 300; private static FServerManager instance = null; private final Map clients = new ConcurrentHashMap<>(); @@ -369,11 +369,13 @@ private void pauseNetGuiGame(final int slotIndex) { for (final Player p : game.getPlayers()) { if (p.getName().equals(localLobby.getSlot(slotIndex).getName())) { - final IGuiGame gui = findGuiForPlayer(p); + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); if (gui instanceof NetGuiGame) { ((NetGuiGame) gui).pause(); + return; } - return; + // Name matched but GUI is not NetGuiGame (e.g. host has same name), + // keep looking for the remote player } } } @@ -398,30 +400,52 @@ private IGuiGame findGuiForPlayer(final Player player) { private void resumeAndResync(final RemoteClient client) { final int slotIndex = client.getIndex(); + System.out.println("[resumeAndResync] Starting for slot " + slotIndex + ", username=" + client.getUsername()); final HostedMatch hostedMatch = localLobby.getHostedMatch(); - if (hostedMatch == null) { return; } + if (hostedMatch == null) { System.out.println("[resumeAndResync] hostedMatch is null, aborting"); return; } final Game game = hostedMatch.getGame(); - if (game == null) { return; } + if (game == null) { System.out.println("[resumeAndResync] game is null, aborting"); return; } + + // Diagnostic: dump all players and guis map + for (final Player p : game.getPlayers()) { + final IGuiGame g = hostedMatch.getGuiForPlayer(p); + System.out.println("[resumeAndResync] Player: '" + p.getName() + "' regPlayer=" + System.identityHashCode(p.getRegisteredPlayer()) + " gui=" + (g == null ? "null" : g.getClass().getSimpleName())); + } + hostedMatch.dumpGuis(); for (final Player p : game.getPlayers()) { if (p.getName().equals(client.getUsername())) { - final IGuiGame gui = findGuiForPlayer(p); + // Use HostedMatch.guis map (authoritative) instead of controller's GUI + // (PlayerControllerHuman.getGui() may not point to the NetGuiGame) + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + System.out.println("[resumeAndResync] Found player, gui=" + (gui == null ? "null" : gui.getClass().getSimpleName()) + " isPaused=" + (gui instanceof NetGuiGame ? ((NetGuiGame) gui).isPaused() : "N/A")); if (gui instanceof NetGuiGame) { final NetGuiGame netGui = (NetGuiGame) gui; netGui.resume(); + // Send current GameView before openView (matches HostedMatch.startGame() ordering) + netGui.updateGameView(); + // Send full game state to the reconnected client netGui.openView(new forge.trackable.TrackableCollection<>(netGui.getLocalPlayers())); + System.out.println("[resumeAndResync] Sent updateGameView + openView"); // Replay current prompt final PlayerControllerHuman pch = findRemoteController(slotIndex); if (pch != null) { pch.getInputQueue().updateObservers(); + System.out.println("[resumeAndResync] Replayed prompt"); + } else { + System.out.println("[resumeAndResync] No PlayerControllerHuman found for prompt replay"); } + return; } - return; + // Name matched but GUI is not NetGuiGame (e.g. host has same name), + // keep looking for the remote player + System.out.println("[resumeAndResync] GUI is not NetGuiGame, continuing search"); } } + System.out.println("[resumeAndResync] No matching player with NetGuiGame found for username=" + client.getUsername()); } private void handleReconnectTimeout(final String username) { From 59c99140560f30f8c480da9b6d2426e83369b613 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:50:46 +1030 Subject: [PATCH 4/7] Fix reconnect player lookup: match by slot index instead of name The game engine deduplicates identical player names (e.g. "2nd MostCromulent") so name-based matching in pauseNetGuiGame/resumeAndResync never finds the remote player. Store slotIndex on NetGuiGame and match by that instead. - NetGuiGame: add slotIndex field and getter - pauseNetGuiGame/resumeAndResync: match by netGui.getSlotIndex() == slotIndex - Remove unused findGuiForPlayer() and dumpGuis() diagnostic methods Co-Authored-By: Claude Opus 4.6 --- .../forge/gamemodes/match/HostedMatch.java | 8 -- .../gamemodes/net/server/FServerManager.java | 89 +++++-------------- .../gamemodes/net/server/NetGuiGame.java | 8 +- 3 files changed, 31 insertions(+), 74 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 45c6bbc3744..3b422a96ce5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -73,14 +73,6 @@ public IGuiGame getGuiForPlayer(final Player player) { return guis.get(player.getRegisteredPlayer()); } - public void dumpGuis() { - if (guis == null) { System.out.println("[dumpGuis] guis is null"); return; } - System.out.println("[dumpGuis] guis map has " + guis.size() + " entries:"); - for (final Map.Entry e : guis.entrySet()) { - System.out.println("[dumpGuis] key=" + System.identityHashCode(e.getKey()) + " name=" + e.getKey().getPlayer().getName() + " -> " + (e.getValue() == null ? "null" : e.getValue().getClass().getSimpleName())); - } - } - public void setStartGameHook(Runnable hook) { startGameHook = hook; } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index a277b9ee7e5..6239c9240f5 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -261,7 +261,7 @@ public IGuiGame getGui(final int index) { } else if (type == LobbySlotType.REMOTE) { for (final RemoteClient client : clients.values()) { if (client.getIndex() == index) { - return new NetGuiGame(client); + return new NetGuiGame(client, index); } } } @@ -368,84 +368,43 @@ private void pauseNetGuiGame(final int slotIndex) { if (game == null) { return; } for (final Player p : game.getPlayers()) { - if (p.getName().equals(localLobby.getSlot(slotIndex).getName())) { - final IGuiGame gui = hostedMatch.getGuiForPlayer(p); - if (gui instanceof NetGuiGame) { - ((NetGuiGame) gui).pause(); - return; - } - // Name matched but GUI is not NetGuiGame (e.g. host has same name), - // keep looking for the remote player - } - } - } - - private IGuiGame findGuiForPlayer(final Player player) { - final HostedMatch hostedMatch = localLobby.getHostedMatch(); - if (hostedMatch == null) { return null; } - // The NetGuiGame is stored per RegisteredPlayer in HostedMatch's guis map, - // but we don't have direct access. Look up via the slot-based getGui. - for (int i = 0; i < localLobby.getNumberOfSlots(); i++) { - final LobbySlot slot = localLobby.getSlot(i); - if (slot != null && player.getName().equals(slot.getName())) { - // For REMOTE slots, getGui creates a new NetGuiGame wrapper each time. - // We need the actual one in use. Find it via the player's controller. - if (player.getController() instanceof PlayerControllerHuman) { - return ((PlayerControllerHuman) player.getController()).getGui(); - } + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + if (gui instanceof NetGuiGame && ((NetGuiGame) gui).getSlotIndex() == slotIndex) { + ((NetGuiGame) gui).pause(); + return; } } - return null; } private void resumeAndResync(final RemoteClient client) { final int slotIndex = client.getIndex(); - System.out.println("[resumeAndResync] Starting for slot " + slotIndex + ", username=" + client.getUsername()); final HostedMatch hostedMatch = localLobby.getHostedMatch(); - if (hostedMatch == null) { System.out.println("[resumeAndResync] hostedMatch is null, aborting"); return; } + if (hostedMatch == null) { return; } final Game game = hostedMatch.getGame(); - if (game == null) { System.out.println("[resumeAndResync] game is null, aborting"); return; } + if (game == null) { return; } - // Diagnostic: dump all players and guis map + // Match by slot index — player names may be deduped by the game engine + // (e.g. "2nd MostCromulent") so name matching is unreliable for (final Player p : game.getPlayers()) { - final IGuiGame g = hostedMatch.getGuiForPlayer(p); - System.out.println("[resumeAndResync] Player: '" + p.getName() + "' regPlayer=" + System.identityHashCode(p.getRegisteredPlayer()) + " gui=" + (g == null ? "null" : g.getClass().getSimpleName())); - } - hostedMatch.dumpGuis(); + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + if (gui instanceof NetGuiGame && ((NetGuiGame) gui).getSlotIndex() == slotIndex) { + final NetGuiGame netGui = (NetGuiGame) gui; + netGui.resume(); - for (final Player p : game.getPlayers()) { - if (p.getName().equals(client.getUsername())) { - // Use HostedMatch.guis map (authoritative) instead of controller's GUI - // (PlayerControllerHuman.getGui() may not point to the NetGuiGame) - final IGuiGame gui = hostedMatch.getGuiForPlayer(p); - System.out.println("[resumeAndResync] Found player, gui=" + (gui == null ? "null" : gui.getClass().getSimpleName()) + " isPaused=" + (gui instanceof NetGuiGame ? ((NetGuiGame) gui).isPaused() : "N/A")); - if (gui instanceof NetGuiGame) { - final NetGuiGame netGui = (NetGuiGame) gui; - netGui.resume(); - - // Send current GameView before openView (matches HostedMatch.startGame() ordering) - netGui.updateGameView(); - - // Send full game state to the reconnected client - netGui.openView(new forge.trackable.TrackableCollection<>(netGui.getLocalPlayers())); - System.out.println("[resumeAndResync] Sent updateGameView + openView"); - - // Replay current prompt - final PlayerControllerHuman pch = findRemoteController(slotIndex); - if (pch != null) { - pch.getInputQueue().updateObservers(); - System.out.println("[resumeAndResync] Replayed prompt"); - } else { - System.out.println("[resumeAndResync] No PlayerControllerHuman found for prompt replay"); - } - return; + // Send current GameView before openView (matches HostedMatch.startGame() ordering) + netGui.updateGameView(); + + // Send full game state to the reconnected client + netGui.openView(new forge.trackable.TrackableCollection<>(netGui.getLocalPlayers())); + + // Replay current prompt + final PlayerControllerHuman pch = findRemoteController(slotIndex); + if (pch != null) { + pch.getInputQueue().updateObservers(); } - // Name matched but GUI is not NetGuiGame (e.g. host has same name), - // keep looking for the remote player - System.out.println("[resumeAndResync] GUI is not NetGuiGame, continuing search"); + return; } } - System.out.println("[resumeAndResync] No matching player with NetGuiGame found for username=" + client.getUsername()); } private void handleReconnectTimeout(final String username) { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java index 492d9a29864..065d2167b18 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/NetGuiGame.java @@ -30,10 +30,16 @@ public class NetGuiGame extends AbstractGuiGame { private final GameProtocolSender sender; + private final int slotIndex; private volatile boolean paused; - public NetGuiGame(final IToClient client) { + public NetGuiGame(final IToClient client, final int slotIndex) { this.sender = new GameProtocolSender(client); + this.slotIndex = slotIndex; + } + + public int getSlotIndex() { + return slotIndex; } public void pause() { From daeb798cc3080b5465de466b5c5ec00d3605872b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:06:37 +1030 Subject: [PATCH 5/7] Add reconnect countdown timer, /skipreconnect and /skiptimeout host commands Replace single-fire 300s timer with periodic 30s countdown that broadcasts M:SS formatted messages. Add /skipreconnect (force AI takeover) and /skiptimeout (disable timer, wait indefinitely) host commands. Suppress slash commands from remote clients. Fix convertToAI to match by slot index instead of player name. Intercept host commands in NetConnectUtil before broadcasting. Co-Authored-By: Claude Opus 4.6 --- .../forge/gamemodes/net/NetConnectUtil.java | 3 + .../gamemodes/net/server/FServerManager.java | 101 +++++++++++++++--- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java b/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java index 02bda81daa1..666fe542bf6 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java @@ -86,6 +86,9 @@ public ClientGameLobby getLobby() { public void send(final NetEvent event) { if (event instanceof MessageEvent) { final MessageEvent message = (MessageEvent) event; + if (server.handleCommand(message.getMessage())) { + return; + } chatInterface.addMessage(new ChatMessage(message.getSource(), message.getMessage())); server.broadcast(event); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index 6239c9240f5..6d629409ad3 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -353,6 +353,68 @@ private void mapNatPort() { // --- Reconnection helper methods --- + public boolean handleCommand(final String messageText) { + if (messageText == null || !messageText.startsWith("/")) { return false; } + final String trimmed = messageText.trim(); + final String lower = trimmed.toLowerCase(); + if (lower.equals("/skipreconnect") || lower.startsWith("/skipreconnect ")) { + return handleSkipReconnectCommand(trimmed); + } else if (lower.equals("/skiptimeout") || lower.startsWith("/skiptimeout ")) { + return handleSkipTimeoutCommand(trimmed); + } + return false; + } + + private boolean handleSkipReconnectCommand(final String command) { + final String target = resolveDisconnectedTarget(command, "/skipreconnect"); + if (target == null) { return true; } // error already broadcast + final RemoteClient client = disconnectedClients.remove(target); + final Timer timer = reconnectTimers.remove(target); + if (timer != null) { timer.cancel(); } + if (isMatchActive()) { + convertToAI(client.getIndex(), target); + } + localLobby.disconnectPlayer(client.getIndex()); + broadcast(new MessageEvent(String.format("Host forced AI takeover for %s.", target))); + return true; + } + + private boolean handleSkipTimeoutCommand(final String command) { + final String target = resolveDisconnectedTarget(command, "/skiptimeout"); + if (target == null) { return true; } + final Timer timer = reconnectTimers.remove(target); + if (timer != null) { timer.cancel(); } + broadcast(new MessageEvent( + String.format("Timeout disabled for %s. Waiting indefinitely for reconnect.", target))); + return true; + } + + private String resolveDisconnectedTarget(final String command, final String prefix) { + if (disconnectedClients.isEmpty()) { + broadcast(new MessageEvent("No players are currently disconnected.")); + return null; + } + final String arg = command.length() > prefix.length() + ? command.substring(prefix.length()).trim() : ""; + if (arg.isEmpty()) { + if (disconnectedClients.size() == 1) { + return disconnectedClients.keySet().iterator().next(); + } + broadcast(new MessageEvent( + String.format("Multiple disconnected players. Specify a name: %s ", prefix))); + return null; + } + if (!disconnectedClients.containsKey(arg)) { + broadcast(new MessageEvent(String.format("No disconnected player named '%s'.", arg))); + return null; + } + return arg; + } + + private static String formatTime(final int totalSeconds) { + return String.format("%d:%02d", totalSeconds / 60, totalSeconds % 60); + } + private PlayerControllerHuman findRemoteController(final int slotIndex) { final IGameController controller = localLobby.getController(slotIndex); if (controller instanceof PlayerControllerHuman) { @@ -434,9 +496,9 @@ private void convertToAI(final int slotIndex, final String username) { if (game == null) { return; } for (final Player p : game.getPlayers()) { - if (p.getName().equals(username)) { - // Create AI controller using the player's existing LobbyPlayer - final LobbyPlayerAi aiLobbyPlayer = new LobbyPlayerAi(username, null); + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + if (gui instanceof NetGuiGame && ((NetGuiGame) gui).getSlotIndex() == slotIndex) { + final LobbyPlayerAi aiLobbyPlayer = new LobbyPlayerAi(p.getName(), null); final PlayerControllerAi aiCtrl = new PlayerControllerAi(game, p, aiLobbyPlayer); p.dangerouslySetController(aiCtrl); @@ -453,9 +515,13 @@ private void convertToAI(final int slotIndex, final String username) { private class MessageHandler extends ChannelInboundHandlerAdapter { @Override public final void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception { - final RemoteClient client = clients.get(ctx.channel()); if (msg instanceof MessageEvent) { - broadcast(new MessageEvent(client.getUsername(), ((MessageEvent) msg).getMessage())); + final String text = ((MessageEvent) msg).getMessage(); + if (text != null && text.startsWith("/")) { + return; // Suppress slash commands from remote clients + } + final RemoteClient client = clients.get(ctx.channel()); + broadcast(new MessageEvent(client.getUsername(), text)); } super.channelRead(ctx, msg); } @@ -477,7 +543,7 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw if (msg instanceof LoginEvent) { final String username = ((LoginEvent) msg).getUsername(); client.setUsername(username); - broadcast(new MessageEvent(String.format("%s joined the room", username))); + broadcast(new MessageEvent(String.format("%s joined the room.", username))); updateLobbyState(); } else if (msg instanceof UpdateLobbyPlayerEvent) { localLobby.applyToSlot(client.getIndex(), (UpdateLobbyPlayerEvent) msg); @@ -515,7 +581,7 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw // Resume and resync resumeAndResync(disconnected); - broadcast(new MessageEvent(String.format("%s has reconnected", username))); + broadcast(new MessageEvent(String.format("%s has reconnected.", username))); System.out.println("Player reconnected: " + username); } else { // Normal login flow @@ -562,22 +628,31 @@ public void channelInactive(final ChannelHandlerContext ctx) throws Exception { // Store for reconnection lookup disconnectedClients.put(username, client); - // Start timeout timer + // Start periodic countdown timer (ticks every 30s) + final int[] remaining = {RECONNECT_TIMEOUT_SECONDS}; final Timer timer = new Timer("reconnect-timeout-" + username, true); reconnectTimers.put(username, timer); - timer.schedule(new TimerTask() { + timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { - handleReconnectTimeout(username); + remaining[0] -= 30; + if (remaining[0] <= 0) { + cancel(); + handleReconnectTimeout(username); + } else { + broadcast(new MessageEvent( + String.format("%s: %s remaining to reconnect.", username, formatTime(remaining[0])))); + } } - }, RECONNECT_TIMEOUT_SECONDS * 1000L); + }, 30_000L, 30_000L); - broadcast(new MessageEvent(String.format("%s disconnected. Waiting %d seconds for reconnect...", username, RECONNECT_TIMEOUT_SECONDS))); + broadcast(new MessageEvent( + String.format("%s disconnected. Waiting %s for reconnect...", username, formatTime(RECONNECT_TIMEOUT_SECONDS)))); System.out.println("Player disconnected mid-game: " + username + " (slot " + client.getIndex() + "). Waiting for reconnect."); } else { // Normal disconnect (lobby or no valid slot) localLobby.disconnectPlayer(client.getIndex()); - broadcast(new MessageEvent(String.format("%s left the room", username))); + broadcast(new MessageEvent(String.format("%s left the room.", username))); broadcast(new LogoutEvent(username)); } super.channelInactive(ctx); From 39a9e94b7f3017edbc96980480e539f459204341 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:43:55 +1030 Subject: [PATCH 6/7] Clean up reconnect code: remove dead state, fix comment durability Remove unused disconnected flag from RemoteClient (pause on NetGuiGame handles send suppression, clients map removal prevents broadcasts). Fix inline comments: remove session-specific labels and case-specific examples per comment durability guideline. Co-Authored-By: Claude Opus 4.6 --- .../forge/gamemodes/net/server/FServerManager.java | 8 +++----- .../forge/gamemodes/net/server/RemoteClient.java | 12 +----------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index 53cd8348cbf..6ebcab26357 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -476,7 +476,7 @@ private void resumeAndResync(final RemoteClient client) { if (game == null) { return; } // Match by slot index — player names may be deduped by the game engine - // (e.g. "2nd MostCromulent") so name matching is unreliable + // so name matching is unreliable for (final Player p : game.getPlayers()) { final IGuiGame gui = hostedMatch.getGuiForPlayer(p); if (gui instanceof NetGuiGame && ((NetGuiGame) gui).getSlotIndex() == slotIndex) { @@ -532,7 +532,7 @@ private void convertToAI(final int slotIndex, final String username) { final PlayerControllerAi aiCtrl = new PlayerControllerAi(game, p, aiLobbyPlayer); p.dangerouslySetController(aiCtrl); - // Clear InputQueue to unblock Path A (game thread waiting on cdlDone) + // Clear InputQueue to unblock the game thread (waiting on cdlDone) final PlayerControllerHuman pch = findRemoteController(slotIndex); if (pch != null) { pch.getInputQueue().clearInputs(); @@ -681,12 +681,10 @@ public void channelInactive(final ChannelHandlerContext ctx) throws Exception { if (isMatchActive() && client.hasValidSlot()) { // Game is active — enter reconnection mode - client.setDisconnected(true); - // Pause the NetGuiGame so sends become no-ops pauseNetGuiGame(client.getIndex()); - // Unblock any Path B waiter (sendAndWait) + // Unblock any pending sendAndWait calls client.getReplyPool().cancelAll(); // Store for reconnection lookup diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java index 09711e9beb0..33beb766e4f 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java @@ -16,28 +16,18 @@ public final class RemoteClient implements IToClient { private String username; private int index = UNASSIGNED_SLOT; // Initialize to -1 to indicate not yet assigned private volatile ReplyPool replies = new ReplyPool(); - private volatile boolean disconnected; public RemoteClient(final Channel channel) { this.channel = channel; } - public boolean isDisconnected() { - return disconnected; - } - - public void setDisconnected(final boolean disconnected) { - this.disconnected = disconnected; - } - /** * Swap the underlying channel for a reconnecting client. - * Updates the channel, creates a fresh ReplyPool, and clears the disconnected flag. + * Updates the channel and creates a fresh ReplyPool. */ public void swapChannel(final Channel newChannel) { this.channel = newChannel; this.replies = new ReplyPool(); - this.disconnected = false; } /** From 937f77f12639e90f5c3057c78acf5ceb69eee1dc Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:06:03 +1030 Subject: [PATCH 7/7] Add host-only hint about /skipreconnect and /skiptimeout on disconnect Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/gamemodes/net/server/FServerManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java index 6ebcab26357..02ac078b2de 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java @@ -710,6 +710,7 @@ public void run() { broadcast(new MessageEvent( String.format("%s disconnected. Waiting %s for reconnect...", username, formatTime(RECONNECT_TIMEOUT_SECONDS)))); + lobbyListener.message(null, "(Host can use /skipreconnect to replace disconnected player with AI, or /skiptimeout to wait indefinitely.)"); System.out.println("Player disconnected mid-game: " + username + " (slot " + client.getIndex() + "). Waiting for reconnect."); } else { // Normal disconnect (lobby or no valid slot)