From 04255aab275a521b49a1a0d87df3b70e5872700d Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:14:32 +0930 Subject: [PATCH 1/4] Harden post-match lobby transition for sequential matches When a match ends, the server now cleans up properly: nulls hostedMatch, resets slot ready states, removes forwarder observers from InputQueues, clears cached RemoteClientGuiGame instances, and broadcasts a fresh LobbyUpdateEvent so clients refresh their lobby UI. Co-Authored-By: Claude Opus 4.6 --- .../java/forge/gamemodes/match/GameLobby.java | 13 +++++++++++++ .../java/forge/gamemodes/match/HostedMatch.java | 17 +++++++++++++++++ .../gamemodes/net/server/ServerGameLobby.java | 7 +++++++ 3 files changed, 37 insertions(+) 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 ada27f0713d..c6bea5ad5da 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java @@ -527,6 +527,7 @@ else if (autoGenerateVariant != null) { //if above checks succeed, return runnable that can be used to finish starting game return () -> { hostedMatch = GuiBase.getInterface().hostMatch(); + hostedMatch.setOnMatchOver(this::onMatchOver); hostedMatch.startMatch(GameType.Constructed, variantTypes, players, guis); for (final Player p : hostedMatch.getGame().getPlayers()) { @@ -542,6 +543,18 @@ else if (autoGenerateVariant != null) { }; } + protected void onMatchOver() { + hostedMatch = null; + gameControllers.clear(); + for (int i = 0; i < getNumberOfSlots(); i++) { + final LobbySlot slot = getSlot(i); + if (slot != null) { + slot.setIsReady(false); + } + } + updateView(true); + } + public final static class GameLobbyData implements Serializable { private static final long serialVersionUID = 9184758307999646864L; 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 7d31c9383bf..ba5b264d5bf 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -56,6 +56,7 @@ public class HostedMatch { public HashMap gameControllers = null; private Runnable startGameHook = null; private Runnable endGameHook = null; + private Runnable onMatchOver = null; private final List humanControllers = Lists.newArrayList(); private Map guis; private int humanCount; @@ -81,6 +82,7 @@ public void setStartGameHook(Runnable hook) { startGameHook = hook; } public void setEndGameHook(Runnable hook) { endGameHook = hook; } + public void setOnMatchOver(Runnable callback) { onMatchOver = callback; } private static GameRules getDefaultRules(final GameType gameType) { final GameRules gameRules = new GameRules(gameType); @@ -364,6 +366,18 @@ public void endCurrentGame() { game = null; + // Must precede shutdownForwarder — observer references become stale after null + for (PlayerControllerHuman hc : humanControllers) { + if (hc.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { + forge.gui.control.GameEventForwarder fwd = ngg.getForwarder(); + if (fwd != null) { + for (PlayerControllerHuman allHc : humanControllers) { + allHc.getInputQueue().deleteObserver(fwd); + } + } + } + } + for (final PlayerControllerHuman humanController : humanControllers) { if (humanController.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { ngg.shutdownForwarder(); @@ -520,6 +534,9 @@ private void addNextGameDecision(final PlayerControllerHuman controller, final N FThreads.invokeInEdtNowOrLater(() -> { endCurrentGame(); isMatchOver = true; + if (onMatchOver != null) { + onMatchOver.run(); + } }); return; // if any player chooses quit, quit the match } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java index cf2fdd6787f..ffec977077c 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java @@ -82,4 +82,11 @@ protected IGuiGame getGui(final int index) { @Override protected void onGameStarted() { } + + @Override + protected void onMatchOver() { + super.onMatchOver(); + FServerManager.getInstance().clearPlayerGuis(); + FServerManager.getInstance().updateLobbyState(); + } } From 82a1404a3c92eaf51c81b2b194bbcdf416144f67 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:50:50 +0930 Subject: [PATCH 2/4] Fix network concede and harden post-game protocol handling Network concede was broken: the client's concede dialog never appeared (instanceof check excluded NetGameController), the game thread stayed blocked after concede (only the conceding player's input queue was released), and game-end events weren't flushed to remote clients. - AbstractGuiGame.concede(): include network controllers in concede check; don't send premature nextGameDecision for network games - PlayerControllerHuman.concede(): release all players' input latches after game ends, not just the conceding player's - HostedMatch: flush GameEventForwarder after game loop exits so remote clients receive GameEventGameFinished - GameProtocolHandler: null-guard getToInvoke() for messages arriving after cleanup, with null reply for non-void methods - GameServerHandler: null-guard getClient() for deregistered clients Co-Authored-By: Claude Opus 4.6 --- .../forge/gamemodes/match/AbstractGuiGame.java | 15 ++++++++++++--- .../java/forge/gamemodes/match/HostedMatch.java | 11 +++++++++++ .../forge/gamemodes/net/GameProtocolHandler.java | 12 ++++++++++++ .../gamemodes/net/server/GameServerHandler.java | 3 ++- .../java/forge/player/PlayerControllerHuman.java | 9 +++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index f520efcde2b..fabde6e55e8 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -352,12 +352,16 @@ public boolean concede() { } if (hasLocalPlayers()) { boolean concedeNeeded = false; - // check if anyone still needs to confirm + // check if anyone still needs to concede for (final IGameController c : getOriginalGameControllers()) { - if (c instanceof PlayerControllerHuman) { - if (((PlayerControllerHuman) c).getPlayer().getOutcome() == null) { + if (c instanceof PlayerControllerHuman pch) { + if (pch.getPlayer().getOutcome() == null) { concedeNeeded = true; } + } else { + // Network client — no access to Player outcome, but game + // is still in progress (isGameOver checked above) + concedeNeeded = true; } } if (concedeNeeded) { @@ -376,6 +380,11 @@ public boolean concede() { } else { return !ignoreConcedeChain; } + if (isNetGame()) { + // Network: concede was sent to server asynchronously. + // Let the server drive game-end flow — don't send nextGameDecision here. + return false; + } if (gameView.isGameOver()) { // Don't immediately close, wait for win/lose screen return false; 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 ba5b264d5bf..81c5831b279 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -305,6 +305,17 @@ public void startGame() { endGameHook.run(); } + // Flush any buffered game events to remote clients so they receive + // GameEventGameOutcome and GameEventGameFinished before we proceed. + for (PlayerControllerHuman hc : humanControllers) { + if (hc.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { + forge.gui.control.GameEventForwarder fwd = ngg.getForwarder(); + if (fwd != null) { + fwd.flush(); + } + } + } + // After game is over... isMatchOver = match.isMatchOver(); if (humanCount == 0) { diff --git a/forge-gui/src/main/java/forge/gamemodes/net/GameProtocolHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/GameProtocolHandler.java index 3e0e64d3810..2d92d1f1d1b 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/GameProtocolHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/GameProtocolHandler.java @@ -47,6 +47,18 @@ public final void channelRead(final ChannelHandlerContext ctx, final Object msg) protocolMethod.checkArgs(args); final Object toInvoke = getToInvoke(ctx); + if (toInvoke == null) { + netLog.info("Ignoring {} — controller no longer available (game ended)", methodName); + // For methods expecting a reply, send null so the client doesn't hang + final Class earlyReturnType = protocolMethod.getReturnType(); + if (!earlyReturnType.equals(Void.TYPE)) { + final IRemote remote = getRemote(ctx); + if (remote != null) { + remote.send(new ReplyEvent(event.getId(), null)); + } + } + return; + } // Pre-call actions (runs on IO thread — blocks all subsequent messages) final long beforeCallStart = System.currentTimeMillis(); diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java b/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java index 5d7fad56e06..f9d2ec0ef4d 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/GameServerHandler.java @@ -33,7 +33,8 @@ protected IRemote getRemote(final ChannelHandlerContext ctx) { @Override protected IGameController getToInvoke(final ChannelHandlerContext ctx) { - return server.getController(getClient(ctx).getIndex()); + final RemoteClient client = getClient(ctx); + return client != null ? server.getController(client.getIndex()) : null; } @Override diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index bb1764ec01b..f3a064077b1 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3344,6 +3344,15 @@ public void concede() { if (player != null) { player.concede(); getGame().getAction().checkGameOverCondition(); + if (getGame().isGameOver()) { + // Release all input latches — the game thread may be blocked + // on a different player's input queue than the one who conceded + for (Player p : getGame().getPlayers()) { + if (p.getController() instanceof PlayerControllerHuman pch) { + pch.getInputQueue().onGameOver(true); + } + } + } } } From 5d574bfd04b20f6e1ef00f8f18eb766ee2ae3e79 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:09:49 +0930 Subject: [PATCH 3/4] Address PR review: merge forwarder cleanup loops and clarify concede comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the new observer-deletion loop into the existing per-controller cleanup loop in HostedMatch.endCurrentGame(). Each iteration now captures the forwarder, deletes observers, then calls shutdownForwarder — the ordering invariant is enforced by adjacency rather than loop split. Update the input-latch-release comment in PlayerControllerHuman.concede() to explain the actual reason the loop is needed: remote-client controllers on the server have no FControlGameEventHandler subscribed, so their input queues are not released by the normal event path. Co-Authored-By: Claude Opus 4.6 --- .../main/java/forge/gamemodes/match/HostedMatch.java | 10 ++-------- .../main/java/forge/player/PlayerControllerHuman.java | 4 ++-- 2 files changed, 4 insertions(+), 10 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 81c5831b279..b5819cbf4d2 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java @@ -377,20 +377,14 @@ public void endCurrentGame() { game = null; - // Must precede shutdownForwarder — observer references become stale after null - for (PlayerControllerHuman hc : humanControllers) { - if (hc.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { + for (final PlayerControllerHuman humanController : humanControllers) { + if (humanController.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { forge.gui.control.GameEventForwarder fwd = ngg.getForwarder(); if (fwd != null) { for (PlayerControllerHuman allHc : humanControllers) { allHc.getInputQueue().deleteObserver(fwd); } } - } - } - - for (final PlayerControllerHuman humanController : humanControllers) { - if (humanController.getGui() instanceof forge.gamemodes.net.server.RemoteClientGuiGame ngg) { ngg.shutdownForwarder(); } humanController.getGui().setGameSpeed(PlaybackSpeed.NORMAL); diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index f3a064077b1..198e0562fe6 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -3345,8 +3345,8 @@ public void concede() { player.concede(); getGame().getAction().checkGameOverCondition(); if (getGame().isGameOver()) { - // Release all input latches — the game thread may be blocked - // on a different player's input queue than the one who conceded + // Remote-client controllers on the server have no FControlGameEventHandler, + // so their input queues won't be released by the normal event path for (Player p : getGame().getPlayers()) { if (p.getController() instanceof PlayerControllerHuman pch) { pch.getInputQueue().onGameOver(true); From b426c214641a46ec268a55b832b3a3ce4814676b Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:59:00 +0930 Subject: [PATCH 4/4] Keep local lobby ready state across sequential matches GameLobby.onMatchOver unconditionally cleared isReady on every slot, which broke LocalLobby: the human slot starts ready and has no UI to re-ready, so the second match failed with "Player X is not ready". Move the reset into ServerGameLobby.onMatchOver so network clients still re-ready between matches while local play is unaffected. Co-Authored-By: Claude Opus 4.7 --- .../src/main/java/forge/gamemodes/match/GameLobby.java | 6 ------ .../java/forge/gamemodes/net/server/ServerGameLobby.java | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) 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 c6bea5ad5da..ddd5f92dbec 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/GameLobby.java @@ -546,12 +546,6 @@ else if (autoGenerateVariant != null) { protected void onMatchOver() { hostedMatch = null; gameControllers.clear(); - for (int i = 0; i < getNumberOfSlots(); i++) { - final LobbySlot slot = getSlot(i); - if (slot != null) { - slot.setIsReady(false); - } - } updateView(true); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java index ffec977077c..872ad23eeeb 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/ServerGameLobby.java @@ -85,6 +85,12 @@ protected void onGameStarted() { @Override protected void onMatchOver() { + for (int i = 0; i < getNumberOfSlots(); i++) { + final LobbySlot slot = getSlot(i); + if (slot != null) { + slot.setIsReady(false); + } + } super.onMatchOver(); FServerManager.getInstance().clearPlayerGuis(); FServerManager.getInstance().updateLobbyState();