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/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java index 85f6db690e3..3b422a96ce5 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,16 @@ 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 setStartGameHook(Runnable hook) { startGameHook = hook; } 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 06f3569fff5..5c5fdf4a270 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/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/client/GameClientHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/client/GameClientHandler.java index cbb5171a011..e1ee3c400fe 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 @@ -36,6 +36,7 @@ final class GameClientHandler extends GameProtocolHandler { private Tracker tracker; private Match match; private Game game; + private GameView pendingGameView; /** * Creates a client-side game handler. @@ -68,6 +69,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: gui.setNetGame(); @@ -145,6 +154,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 aedfa4ffb7b..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 @@ -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.BuildInfo; import forge.util.IterableUtil; import forge.util.Localizer; @@ -37,11 +42,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 = 300; + 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(); @@ -162,6 +172,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) { @@ -274,7 +291,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); } } } @@ -364,19 +381,182 @@ 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) { + 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()) { + final IGuiGame gui = hostedMatch.getGuiForPlayer(p); + if (gui instanceof NetGuiGame && ((NetGuiGame) gui).getSlotIndex() == slotIndex) { + ((NetGuiGame) gui).pause(); + return; + } + } + } + + 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; } + + // Match by slot index — player names may be deduped by the game engine + // 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) { + 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())); + + // 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()) { + 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); + + // Clear InputQueue to unblock the 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 { - final RemoteClient client = clients.get(ctx.channel()); if (msg instanceof MessageEvent) { + 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()); String username = client.getUsername(); - String message = ((MessageEvent) msg).getMessage(); - // Append (Host) indicator for the host player if (client.getIndex() == 0) { username = username + " (Host)"; } - broadcast(new MessageEvent(username, message)); + broadcast(new MessageEvent(username, text)); } super.channelRead(ctx, msg); } @@ -399,9 +579,9 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw final String username = event.getUsername(); client.setUsername(username); if (client.getIndex() == 0) { - broadcast(new MessageEvent(String.format("Lobby hosted by %s", username))); + broadcast(new MessageEvent(String.format("Lobby hosted by %s.", username))); } else { - broadcast(new MessageEvent(String.format("%s joined the lobby", username))); + broadcast(new MessageEvent(String.format("%s joined the lobby.", username))); } updateLobbyState(); } else if (msg instanceof UpdateLobbyPlayerEvent event) { @@ -429,27 +609,55 @@ private class LobbyInputHandler extends ChannelInboundHandlerAdapter { public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception { final RemoteClient client = clients.get(ctx.channel()); if (msg instanceof LoginEvent event) { - 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); - // Warn if client version differs from host - final String clientVersion = event.getVersion(); - final String hostVersion = BuildInfo.getVersionString(); - if (clientVersion == null) { - broadcast(new MessageEvent(String.format( - "Warning: Could not determine %s's Forge version. " - + "Please use the same version as the host to avoid network compatibility issues.", - event.getUsername()))); - } else if (!clientVersion.equals(hostVersion)) { - broadcast(new MessageEvent(String.format( - "Warning: %s is using Forge version %s (host: %s). " - + "Please use the same version as the host to avoid network compatibility issues.", - event.getUsername(), clientVersion, hostVersion))); + // Normal login flow + final int index = localLobby.connectPlayer(event.getUsername(), event.getAvatarIndex(), event.getSleeveIndex()); + if (index == -1) { + ctx.close(); + } else { + client.setIndex(index); + // Warn if client version differs from host + final String clientVersion = event.getVersion(); + final String hostVersion = BuildInfo.getVersionString(); + if (clientVersion == null) { + broadcast(new MessageEvent(String.format( + "Warning: Could not determine %s's Forge version. " + + "Please use the same version as the host to avoid network compatibility issues.", + event.getUsername()))); + } else if (!clientVersion.equals(hostVersion)) { + broadcast(new MessageEvent(String.format( + "Warning: %s is using Forge version %s (host: %s). " + + "Please use the same version as the host to avoid network compatibility issues.", + event.getUsername(), clientVersion, hostVersion))); + } + broadcast(event); + updateLobbyState(); } - broadcast(event); - updateLobbyState(); } } else if (msg instanceof UpdateLobbyPlayerEvent event) { updateSlot(client.getIndex(), event); @@ -464,10 +672,52 @@ 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 lobby", username))); - broadcast(new LogoutEvent(username)); + + if (isMatchActive() && client.hasValidSlot()) { + // Game is active — enter reconnection mode + // Pause the NetGuiGame so sends become no-ops + pauseNetGuiGame(client.getIndex()); + + // Unblock any pending sendAndWait calls + client.getReplyPool().cancelAll(); + + // Store for reconnection lookup + disconnectedClients.put(username, client); + + // 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.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + 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])))); + } + } + }, 30_000L, 30_000L); + + 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) + localLobby.disconnectPlayer(client.getIndex()); + broadcast(new MessageEvent(String.format("%s left the lobby.", 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 ca1cc6c860a..70e51629f09 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,37 @@ public class NetGuiGame extends AbstractGuiGame { private final GameProtocolSender sender; - public NetGuiGame(final IToClient client) { + private final int slotIndex; + private volatile boolean paused; + + public NetGuiGame(final IToClient client, final int slotIndex) { this.sender = new GameProtocolSender(client); + this.slotIndex = slotIndex; + } + + public int getSlotIndex() { + return slotIndex; + } + + 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 +249,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 +266,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 +336,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 407169eaba7..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 @@ -9,14 +9,35 @@ public final class RemoteClient implements IToClient { - private final Channel channel; + /** Special value indicating the client hasn't been assigned a slot yet. */ + public static final int UNASSIGNED_SLOT = -1; + + private volatile Channel channel; private String username; - private int index; - private ReplyPool replies = new ReplyPool(); + private int index = UNASSIGNED_SLOT; // Initialize to -1 to indicate not yet assigned + private volatile ReplyPool replies = new ReplyPool(); + public RemoteClient(final Channel channel) { this.channel = channel; } + /** + * Swap the underlying channel for a reconnecting client. + * Updates the channel and creates a fresh ReplyPool. + */ + public void swapChannel(final Channel newChannel) { + this.channel = newChannel; + this.replies = new ReplyPool(); + } + + /** + * 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);