Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,16 @@ private void join(final String url) {
});

final ChatMessage result = NetConnectUtil.join(url, VSubmenuOnlineLobby.SINGLETON_INSTANCE, FNetOverlay.SINGLETON_INSTANCE);
if(Objects.equals(result.getMessage(), ForgeConstants.CLOSE_CONN_COMMAND)) {
String message = result.getMessage();
if(Objects.equals(message, ForgeConstants.CLOSE_CONN_COMMAND)) {
FOptionPane.showErrorDialog(Localizer.getInstance().getMessage("UnableConnectToServer", url));
SOverlayUtils.hideOverlay();
} else if (Objects.equals(result.getMessage(), ForgeConstants.INVALID_HOST_COMMAND)) {
} else if (message != null && message.startsWith(ForgeConstants.CONN_ERROR_PREFIX)) {
// Show detailed connection error
String errorDetail = message.substring(ForgeConstants.CONN_ERROR_PREFIX.length());
FOptionPane.showErrorDialog(errorDetail, Localizer.getInstance().getMessage("lblConnectionError"));
SOverlayUtils.hideOverlay();
} else if (Objects.equals(message, ForgeConstants.INVALID_HOST_COMMAND)) {
FOptionPane.showErrorDialog(Localizer.getInstance().getMessage("lblDetectedInvalidHostAddress", url));
SOverlayUtils.hideOverlay();
} else {
Expand Down
10 changes: 8 additions & 2 deletions forge-gui-mobile/src/forge/screens/online/OnlineLobbyScreen.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,16 @@ public void onActivate() {
final IOnlineChatInterface chatInterface = (IOnlineChatInterface)OnlineScreen.Chat.getScreen();
if (joinServer) {
result[0] = NetConnectUtil.join(url, OnlineLobbyScreen.this, chatInterface);
if (result[0].getMessage() == ForgeConstants.CLOSE_CONN_COMMAND) { //this message is returned via netconnectutil on exception
String message = result[0].getMessage();
if (ForgeConstants.CLOSE_CONN_COMMAND.equals(message)) { //this message is returned via netconnectutil on exception
closeConn(Forge.getLocalizer().getMessage("UnableConnectToServer", url));
return;
} else if (result[0].getMessage() == ForgeConstants.INVALID_HOST_COMMAND) {
} else if (message != null && message.startsWith(ForgeConstants.CONN_ERROR_PREFIX)) {
// Show detailed connection error
String errorDetail = message.substring(ForgeConstants.CONN_ERROR_PREFIX.length());
closeConn(errorDetail);
return;
} else if (ForgeConstants.INVALID_HOST_COMMAND.equals(message)) {
closeConn(Forge.getLocalizer().getMessage("lblDetectedInvalidHostAddress", url));
return;
}
Expand Down
8 changes: 8 additions & 0 deletions forge-gui/res/languages/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ AresetMatchScreenLayout=This will reset the layout of the Match screen.\n If you
TresetMatchScreenLayout=Reset Match Screen Layout
OKresetMatchScreenLayout=Match Screen layout has been reset.
UnableConnectToServer=Unable to connect to server with host {0}.
lblConnectionError=Connection Error
lblConnectionFailedTo=Failed to connect to {0}:{1}
lblConnectionRefused=Connection refused. Please verify:\n- The host is running and accepting connections\n- The port number is correct\n- No firewall is blocking the connection
lblUnknownHost=Unknown host. Please check the hostname or IP address is correct.
lblConnectionTimeout=Connection timed out. The server may be unreachable or behind a firewall.
lblNoRouteToHost=No route to host. Please check your network connection.
#EMenuGroup.java
lblSanctionedFormats=Sanctioned Formats
lblOnlineMultiplayer=Online Multiplayer
Expand Down Expand Up @@ -1520,6 +1526,8 @@ lblConcedeCurrentGame=This will concede the current game and you will lose.\n\nC
lblConcedeTitle=Concede Game?
lblConcede=Concede
lblWaitingforActions=Waiting for actions...
lblWaitingForPlayer=Waiting for {0}...
lblWaitingForPlayerWithTime=Waiting for {0}... ({1})
lblCloseGameSpectator=This will close this game and you will not be able to resume watching it.\n\nClose anyway?
lblCloseGame=Close Game?
lblWaitingForOpponent=Waiting for opponent...
Expand Down
93 changes: 91 additions & 2 deletions forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,12 @@ public final boolean mayAutoPass(final PlayerView player) {

private Timer awaitNextInputTimer;
private TimerTask awaitNextInputTask;
private volatile long awaitStartTime;

@Override
public final void awaitNextInput() {
checkAwaitNextInputTimer();
awaitStartTime = System.currentTimeMillis();
//delay updating prompt to await next input briefly so buttons don't flicker disabled then enabled
awaitNextInputTask = new TimerTask() {
@Override
Expand All @@ -459,14 +461,42 @@ public void run() {
synchronized (awaitNextInputTimer) {
if (awaitNextInputTask != null) {
updatePromptForAwait(getCurrentPlayer());
awaitNextInputTask = null;
// In network games, reschedule every second to update elapsed time
if (GuiBase.isNetworkplay()) {
scheduleTimerUpdate();
} else {
awaitNextInputTask = null;
}
}
}
});
}
};
awaitNextInputTimer.schedule(awaitNextInputTask, 250);
}

private void scheduleTimerUpdate() {
awaitNextInputTask = new TimerTask() {
@Override
public void run() {
FThreads.invokeInEdtLater(() -> {
checkAwaitNextInputTimer();
synchronized (awaitNextInputTimer) {
if (awaitNextInputTask != null) {
showPromptMessage(getCurrentPlayer(), getWaitingMessage(getCurrentPlayer()));
scheduleTimerUpdate();
}
}
});
}
};
try {
awaitNextInputTimer.schedule(awaitNextInputTask, 1000);
} catch (final IllegalStateException ex) {
// Timer was cancelled between check and schedule
}
}

private void checkAwaitNextInputTimer() {
if (awaitNextInputTimer == null) {
String name = "?";
Expand All @@ -477,10 +507,68 @@ private void checkAwaitNextInputTimer() {
}

protected final void updatePromptForAwait(final PlayerView playerView) {
showPromptMessage(playerView, Localizer.getInstance().getMessage("lblWaitingForOpponent"));
showPromptMessage(playerView, getWaitingMessage(playerView));
updateButtons(playerView, false, false, false);
}

private String getWaitingMessage(final PlayerView forPlayer) {
Localizer localizer = Localizer.getInstance();

if (GuiBase.isNetworkplay() && gameView != null && !gameView.isGameOver()) {
String name = findWaitingForPlayerName(forPlayer);
if (name != null) {
String timeStr = getElapsedTimeString();
if (timeStr != null) {
return localizer.getMessage("lblWaitingForPlayerWithTime", name, timeStr);
}
return localizer.getMessage("lblWaitingForPlayer", name);
}
}

return localizer.getMessage("lblWaitingForOpponent");
}

private String findWaitingForPlayerName(final PlayerView forPlayer) {
if (gameView.getPlayers() != null) {
for (PlayerView pv : gameView.getPlayers()) {
if (pv.getHasPriority() && (forPlayer == null || pv.getId() != forPlayer.getId())) {
return pv.getName();
}
}
}
// Fallback to turn player during mulligan/setup
PlayerView turnPlayer = gameView.getPlayerTurn();
if (turnPlayer != null && (forPlayer == null || turnPlayer.getId() != forPlayer.getId())) {
return turnPlayer.getName();
}
// Fallback to any non-local player
if (gameView.getPlayers() != null) {
for (PlayerView pv : gameView.getPlayers()) {
if (forPlayer != null && pv.getId() == forPlayer.getId()) {
continue;
}
if (!isLocalPlayer(pv)) {
return pv.getName();
}
}
}
return null;
}

private String getElapsedTimeString() {
if (awaitStartTime == 0) {
return null;
}
long elapsedSec = (System.currentTimeMillis() - awaitStartTime) / 1000;
if (elapsedSec < 2) {
return null;
}
if (elapsedSec < 60) {
return elapsedSec + "s";
}
return String.format("%d:%02d", elapsedSec / 60, elapsedSec % 60);
}

@Override
public final void cancelAwaitNextInput() {
if (awaitNextInputTimer == null) {
Expand All @@ -495,6 +583,7 @@ public final void cancelAwaitNextInput() {
awaitNextInputTask = null;
}
}
awaitStartTime = 0;
}

@Override
Expand Down
41 changes: 39 additions & 2 deletions forge-gui/src/main/java/forge/gamemodes/net/NetConnectUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,47 @@ public ClientGameLobby getLobby() {
client.connect();
}
catch (Exception ex) {
//return a message to close the connection so we will not crash...
return new ChatMessage(null, ForgeConstants.CLOSE_CONN_COMMAND);
// Return error with details for GUI display
String errorDetail = getConnectionErrorMessage(ex, hostname, port);
return new ChatMessage(null, ForgeConstants.CONN_ERROR_PREFIX + errorDetail);
}

return new ChatMessage(null, Localizer.getInstance().getMessage("lblConnectedIPPort", hostname, String.valueOf(port)));
}

/**
* Generate a user-friendly error message for connection failures.
*/
private static String getConnectionErrorMessage(Exception ex, String hostname, int port) {
Localizer localizer = Localizer.getInstance();
StringBuilder sb = new StringBuilder();

// Get the root cause for better error messages
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
String causeName = cause.getClass().getSimpleName();

sb.append(localizer.getMessage("lblConnectionFailedTo", hostname, String.valueOf(port)));
sb.append("\n\n");

// Provide specific messages for common error types
if (causeName.contains("ConnectException") || causeName.contains("ConnectionRefused")) {
sb.append(localizer.getMessage("lblConnectionRefused"));
} else if (causeName.contains("UnknownHost")) {
sb.append(localizer.getMessage("lblUnknownHost"));
} else if (causeName.contains("Timeout") || causeName.contains("TimedOut")) {
sb.append(localizer.getMessage("lblConnectionTimeout"));
} else if (causeName.contains("NoRouteToHost")) {
sb.append(localizer.getMessage("lblNoRouteToHost"));
} else {
// Generic error with the exception message
String msg = cause.getMessage();
if (msg != null && !msg.isEmpty()) {
sb.append(msg);
} else {
sb.append(causeName);
}
}

return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,34 @@ public void updateLobbyState() {

public void updateSlot(final int index, final UpdateLobbyPlayerEvent event) {
localLobby.applyToSlot(index, event);

if (event.getReady() != null) {
broadcastReadyState(localLobby.getSlot(index).getName(), event.getReady());
}
}

private void broadcastReadyState(String playerName, boolean isReady) {
int readyCount = 0;
int totalPlayers = 0;
for (int i = 0; i < localLobby.getNumberOfSlots(); i++) {
LobbySlot slot = localLobby.getSlot(i);
if (slot.getType() == LobbySlotType.LOCAL || slot.getType() == LobbySlotType.REMOTE) {
totalPlayers++;
if (slot.isReady()) {
readyCount++;
}
}
}
if (isReady) {
broadcast(new MessageEvent(String.format("%s is ready (%d/%d players ready)",
playerName, readyCount, totalPlayers)));
if (readyCount == totalPlayers && totalPlayers > 1) {
broadcast(new MessageEvent("All players ready to start game!"));
}
} else {
broadcast(new MessageEvent(String.format("%s is not ready (%d/%d players ready)",
playerName, readyCount, totalPlayers)));
}
}

public IGuiGame getGui(final int index) {
Expand Down Expand Up @@ -339,7 +367,14 @@ private class MessageHandler extends ChannelInboundHandlerAdapter {
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()));
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));
}
super.channelRead(ctx, msg);
}
Expand All @@ -359,12 +394,31 @@ public void channelActive(final ChannelHandlerContext ctx) throws Exception {
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
final RemoteClient client = clients.get(ctx.channel());
if (msg instanceof LoginEvent) {
final String username = ((LoginEvent) msg).getUsername();
final LoginEvent event = (LoginEvent) msg;
final String username = event.getUsername();
client.setUsername(username);
broadcast(new MessageEvent(String.format("%s joined the room", username)));
if (client.getIndex() == 0) {
broadcast(new MessageEvent(String.format("Lobby hosted by %s", username)));
} else {
broadcast(new MessageEvent(String.format("%s joined the lobby", username)));
}
updateLobbyState();
} else if (msg instanceof UpdateLobbyPlayerEvent) {
localLobby.applyToSlot(client.getIndex(), (UpdateLobbyPlayerEvent) msg);
UpdateLobbyPlayerEvent updateEvent = (UpdateLobbyPlayerEvent) msg;
localLobby.applyToSlot(client.getIndex(), updateEvent);
if (updateEvent.getName() != null) {
String oldName = client.getUsername();
String newName = updateEvent.getName();
if (!newName.equals(oldName)) {
client.setUsername(newName);
broadcast(new MessageEvent(String.format("%s changed their name to %s", oldName, newName)));
}
}
if (updateEvent.getReady() != null) {
broadcastReadyState(client.getUsername(), updateEvent.getReady());
}
// Return to prevent duplicate processing by LobbyInputHandler
return;
}
super.channelRead(ctx, msg);
}
Expand All @@ -386,10 +440,9 @@ public void channelRead(final ChannelHandlerContext ctx, final Object msg) throw
}
} else if (msg instanceof UpdateLobbyPlayerEvent) {
updateSlot(client.getIndex(), (UpdateLobbyPlayerEvent) msg);
} else if (msg instanceof MessageEvent) {
final MessageEvent event = (MessageEvent) msg;
lobbyListener.message(event.getSource(), event.getMessage());
}
// Note: MessageEvent is handled by MessageHandler, not here
// to avoid duplicate display on host's chat
super.channelRead(ctx, msg);
}
}
Expand All @@ -400,7 +453,7 @@ public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
final RemoteClient client = clients.remove(ctx.channel());
final String username = client.getUsername();
localLobby.disconnectPlayer(client.getIndex());
broadcast(new MessageEvent(String.format("%s left the room", username)));
broadcast(new MessageEvent(String.format("%s left the lobby", username)));
broadcast(new LogoutEvent(username));
super.channelInactive(ctx);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ public final class ForgeConstants {
public static final String ITEM_VIEW_PREFS_FILE = USER_PREFS_DIR + "item_view.preferences";
public static final String CLOSE_CONN_COMMAND = "<<_EM_ESOLC_<<";
public static final String INVALID_HOST_COMMAND = "<<_TSOH_DILAVNI_<<";
public static final String CONN_ERROR_PREFIX = "<<_CONN_ERROR_>>:";

// data that has defaults in the program dir but overrides/additions in the user dir
private static final String _DEFAULTS_DIR = RES_DIR + "defaults" + PATH_SEPARATOR;
Expand Down