From a48336db4fa864bdb35bd21b6bcfa1beccf8ab98 Mon Sep 17 00:00:00 2001 From: huanghongxun Date: Thu, 30 Sep 2021 17:27:43 +0800 Subject: [PATCH] feat(multiplayer): kick player. --- .../ui/multiplayer/MultiplayerChannel.java | 15 ++- .../ui/multiplayer/MultiplayerClient.java | 49 ++++++---- .../ui/multiplayer/MultiplayerManager.java | 4 + .../hmcl/ui/multiplayer/MultiplayerPage.java | 43 +++++++-- .../ui/multiplayer/MultiplayerPageSkin.java | 9 +- .../ui/multiplayer/MultiplayerServer.java | 96 +++++++++++++++---- .../resources/assets/lang/I18N.properties | 2 + .../resources/assets/lang/I18N_zh.properties | 2 + .../assets/lang/I18N_zh_CN.properties | 2 + 9 files changed, 177 insertions(+), 45 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java index b38c9709b..ee7a0d940 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java @@ -70,7 +70,8 @@ public final class MultiplayerChannel { property = "type", subtypes = { @JsonSubtype(clazz = JoinResponse.class, name = "join"), - @JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive") + @JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive"), + @JsonSubtype(clazz = KickResponse.class, name = "kick") } ) public static class Response { @@ -101,6 +102,18 @@ public final class MultiplayerChannel { } } + public static class KickResponse extends Response { + private final String msg; + + public KickResponse(String msg) { + this.msg = msg; + } + + public String getMsg() { + return msg; + } + } + public static class CatoClient extends Event { private final String username; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java index 901a79fcd..8a9ee00a6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java @@ -26,6 +26,7 @@ import java.io.*; import java.net.ConnectException; import java.net.InetAddress; import java.net.Socket; + import java.util.logging.Level; import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; import static org.jackhuang.hmcl.util.Logging.LOG; @@ -38,6 +39,7 @@ public class MultiplayerClient extends Thread { private final EventManager onConnected = new EventManager<>(); private final EventManager onDisconnected = new EventManager<>(); + private final EventManager onKicked = new EventManager<>(); public MultiplayerClient(String id, int port) { this.id = id; @@ -63,6 +65,10 @@ public class MultiplayerClient extends Thread { return onDisconnected; } + public EventManager onKicked() { + return onDisconnected; + } + @Override public void run() { LOG.info("Connecting to 127.0.0.1:" + port); @@ -72,34 +78,37 @@ public class MultiplayerClient extends Thread { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) { LOG.info("Connected to 127.0.0.1:" + port); + socket.setKeepAlive(true); + writer.write(JsonUtils.UGLY_GSON.toJson(new JoinRequest(MultiplayerManager.CATO_VERSION, id))); writer.newLine(); writer.flush(); LOG.fine("Sent join request with id=" + id); - String line = reader.readLine(); - if (line == null) { - return; - } - - JoinResponse response = JsonUtils.fromNonNullJson(line, JoinResponse.class); - setGamePort(response.getPort()); - onConnected.fireEvent(new ConnectedEvent(this, response.getPort())); - - LOG.fine("Received join response with port " + response.getPort()); - - while (!isInterrupted()) { - writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(new KeepAliveResponse(System.currentTimeMillis())))); - writer.newLine(); - writer.flush(); - - try { - Thread.sleep(1500); - } catch (InterruptedException ignored) { - LOG.warning("MultiplayerClient interrupted"); + String line; + while ((line = reader.readLine()) != null) { + if (isInterrupted()) { return; } + + LOG.fine("Message from server:" + line); + + Response response = JsonUtils.fromNonNullJson(line, Response.class); + + if (response instanceof JoinResponse) { + JoinResponse joinResponse = JsonUtils.fromNonNullJson(line, JoinResponse.class); + setGamePort(joinResponse.getPort()); + onConnected.fireEvent(new ConnectedEvent(this, joinResponse.getPort())); + + LOG.fine("Received join response with port " + joinResponse.getPort()); + } else if (response instanceof KickResponse) { + onKicked.fireEvent(new Event(this)); + + LOG.fine("Kicked by the server"); + } else { + LOG.log(Level.WARNING, "Unrecognized packet from server:" + line); + } } } catch (ConnectException e) { LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java index 7885c784f..917c3688d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java @@ -48,6 +48,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.logging.Level; import java.util.regex.Matcher; @@ -146,6 +147,9 @@ public final class MultiplayerManager { future.completeExceptionally(e); } }); + client.onKicked().register(kickedEvent -> { + future.completeExceptionally(new CancellationException()); + }); client.start(); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java index 4d7090fac..f14632e89 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java @@ -28,7 +28,6 @@ import javafx.collections.ObservableList; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.control.Skin; -import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.task.Schedulers; @@ -41,11 +40,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.StringUtils; import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.logging.Level; import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -211,6 +210,9 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware int gamePort = result.getAd(); try { MultiplayerManager.CatoSession session = MultiplayerManager.createSession(config().getMultiplayerToken(), result.getMotd(), gamePort); + session.getServer().onClientAdding().register(event -> { + + }); session.getServer().onClientAdded().register(event -> { runInFX(() -> { clients.add(event); @@ -263,19 +265,37 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware .thenAcceptAsync(session -> { initCatoSession(session); + AtomicBoolean kicked = new AtomicBoolean(); + session.getClient().onDisconnected().register(() -> { runInFX(() -> { stopCatoSession(); - Controllers.dialog(i18n("multiplayer.session.join.lost_connection")); + if (!kicked.get()) { + Controllers.dialog(i18n("multiplayer.session.join.lost_connection")); + } + }); + }); + + session.getClient().onKicked().register(() -> { + runInFX(() -> { + kicked.set(true); + Controllers.dialog(i18n("multiplayer.session.join.kicked")); }); }); gamePort.set(session.getClient().getGamePort()); setMultiplayerState(MultiplayerManager.State.SLAVE); resolve.run(); - }, Platform::runLater).exceptionally(throwable -> { - LOG.log(Level.WARNING, "Failed to join sessoin"); - reject.accept(i18n("multiplayer.session.error")); + }, Platform::runLater) + .exceptionally(throwable -> { + if (throwable instanceof CancellationException) { + LOG.info("Connection rejected by the server"); + reject.accept(i18n("multiplayer.session.join.rejected")); + return null; + } else { + LOG.log(Level.WARNING, "Failed to join sessoin"); + reject.accept(i18n("multiplayer.session.error")); + } return null; }); } catch (MultiplayerManager.IncompatibleCatoVersionException e) { @@ -286,6 +306,17 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator()))); } + public void kickPlayer(MultiplayerChannel.CatoClient client) { + if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) { + throw new IllegalStateException("CatoSession not ready"); + } + + Controllers.confirm(i18n("multiplayer.session.create.members.kick.prompt"), i18n("multiplayer.session.create.members.kick"), MessageDialogPane.MessageType.WARNING, + () -> { + getSession().getServer().kickPlayer(client); + }, null); + } + public void closeRoom() { if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) { throw new IllegalStateException("CatoSession not ready"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java index b2229caa4..2b2ffeb8f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java @@ -33,6 +33,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; +import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -315,12 +316,18 @@ public class MultiplayerPageSkin extends SkinBase { } } - private static class ClientItem extends StackPane { + private class ClientItem extends StackPane { ClientItem(MultiplayerChannel.CatoClient client) { BorderPane pane = new BorderPane(); pane.setPadding(new Insets(8)); pane.setLeft(new Label(client.getUsername())); + JFXButton kickButton = new JFXButton(); + kickButton.setGraphic(SVG.close(Theme.blackFillBinding(), 16, 16)); + kickButton.getStyleClass().add("toggle-icon-tiny"); + kickButton.setOnAction(e -> getSkinnable().kickPlayer(client)); + pane.setRight(kickButton); + RipplerContainer container = new RipplerContainer(pane); getChildren().setAll(container); getStyleClass().add("md-list-cell"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java index 28e91db07..9bcfbe2bb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java @@ -20,12 +20,17 @@ package org.jackhuang.hmcl.ui.multiplayer; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; +import org.jackhuang.hmcl.util.FutureCallback; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import java.io.*; import java.net.ServerSocket; import java.net.Socket; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.logging.Level; import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; @@ -35,9 +40,12 @@ public class MultiplayerServer extends Thread { private ServerSocket socket; private final int gamePort; + private FutureCallback onClientAdding; private final EventManager onClientAdded = new EventManager<>(); private final EventManager onClientDisconnected = new EventManager<>(); - private final EventManager onKeepAlive = new EventManager<>(); + + private final Map clients = new ConcurrentHashMap<>(); + private final Map nameClientMap = new ConcurrentHashMap<>(); public MultiplayerServer(int gamePort) { this.gamePort = gamePort; @@ -46,6 +54,10 @@ public class MultiplayerServer extends Thread { setDaemon(true); } + public void setOnClientAdding(FutureCallback callback) { + onClientAdding = callback; + } + public EventManager onClientAdded() { return onClientAdded; } @@ -54,10 +66,6 @@ public class MultiplayerServer extends Thread { return onClientDisconnected; } - public EventManager onKeepAlive() { - return onKeepAlive; - } - public void startServer() throws IOException { startServer(0); } @@ -92,12 +100,31 @@ public class MultiplayerServer extends Thread { } } + public void kickPlayer(CatoClient player) { + Client client = nameClientMap.get(player.getUsername()); + if (client == null) return; + + try { + if (client.socket.isConnected()) { + client.write(new KickResponse()); + client.socket.close(); + } + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to kick player " + player.getUsername() + ". Maybe already disconnected?", e); + } + } + private void handleClient(Socket targetSocket) { + String address = targetSocket.getRemoteSocketAddress().toString(); String clientName = null; - LOG.info("Accepted client " + targetSocket.getRemoteSocketAddress()); + LOG.info("Accepted client " + address); try (Socket clientSocket = targetSocket; BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { + clientSocket.setKeepAlive(true); + Client client = new Client(clientSocket, writer); + clients.put(address, client); + String line; while ((line = reader.readLine()) != null) { if (isInterrupted()) { @@ -110,19 +137,35 @@ public class MultiplayerServer extends Thread { if (request instanceof JoinRequest) { JoinRequest joinRequest = (JoinRequest) request; LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername()); - - writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(new JoinResponse(gamePort)))); - writer.newLine(); - writer.flush(); - clientName = joinRequest.getUsername(); - onClientAdded.fireEvent(new CatoClient(this, joinRequest.getUsername())); - } else if (request instanceof KeepAliveRequest) { - writer.write(JsonUtils.UGLY_GSON.toJson(new KeepAliveResponse(System.currentTimeMillis()))); - writer.newLine(); - writer.flush(); - onKeepAlive.fireEvent(new Event(this)); + CatoClient catoClient = new CatoClient(this, clientName); + nameClientMap.put(clientName, client); + onClientAdded.fireEvent(catoClient); + + if (onClientAdding != null) { + onClientAdding.call(catoClient, () -> { + try { + client.write(new JoinResponse(gamePort)); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to send join response.", e); + try { + socket.close(); + } catch (IOException ioException) { + LOG.log(Level.WARNING, "Failed to close socket caused by join response sending failure.", e); + this.interrupt(); + } + } + }, msg -> { + try { + client.write(new KickResponse(msg)); + LOG.info("Rejected join request from id=" + joinRequest.getUsername()); + socket.close(); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to send kick response.", e); + } + }); + } } else { LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line); } @@ -135,6 +178,25 @@ public class MultiplayerServer extends Thread { if (clientName != null) { onClientDisconnected.fireEvent(new CatoClient(this, clientName)); } + + clients.remove(address); + if (clientName != null) nameClientMap.remove(clientName); + } + } + + private static class Client { + public final Socket socket; + public final BufferedWriter writer; + + public Client(Socket socket, BufferedWriter writer) { + this.socket = socket; + this.writer = writer; + } + + public synchronized void write(Object object) throws IOException { + writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(object))); + writer.newLine(); + writer.flush(); } } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index d02e022f2..434bdf2d4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -644,8 +644,10 @@ multiplayer.session.join.hint=You must obtain the invitation code from the gamer multiplayer.session.join.invitation_code=Invitation code multiplayer.session.join.invitation_code.error=Incorrect invitation code. Please obtain invitation code from the player who creates the multiplayer room. multiplayer.session.join.invitation_code.version=Versions of multiplayer functionalities are not the same among you. +multiplayer.session.join.kicked=You have been kicked by the session holder. You will lost connection with the multiplayer session. multiplayer.session.join.lost_connection=Lost connection with the multiplayer session. Maybe the session is destroyed by the creator, or you cannot establish connection with this session. multiplayer.session.join.port.error=Cannot find available local network port for listening. Please ensure that HMCL has the permission to listen on a port. +multiplayer.session.join.rejected=Your connection is rejected by the session holder. multiplayer.session.members=Room Members multiplayer.session.quit=Quit Room multiplayer.session.quit.warning=After quiting room, you will lost the connection with the server. Continue? diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index ba5188c03..1270d1b67 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -643,8 +643,10 @@ multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要 multiplayer.session.join.invitation_code=邀請碼 multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼 multiplayer.session.join.invitation_code.version=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。 +multiplayer.session.join.kicked=你已經被房主踢出房間,你將與房間失去連接。 multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。 multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。 +multiplayer.session.join.rejected=你被房主拒絕連接。 multiplayer.session.members=房間成員 multiplayer.session.quit=退出房間 multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續? diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 0c639d426..6337db44a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -643,8 +643,10 @@ multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要 multiplayer.session.join.invitation_code=邀请码 multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码 multiplayer.session.join.invitation_code.version=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。 +multiplayer.session.join.kicked=你已经被房主踢出房间,你将与房间失去连接。 multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。 multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。 +multiplayer.session.join.rejected=你被房主拒绝连接。 multiplayer.session.members=房间成员 multiplayer.session.quit=退出房间 multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?