feat(multiplayer): kick player.

This commit is contained in:
huanghongxun
2021-09-30 17:27:43 +08:00
parent e69ef0ce25
commit a48336db4f
9 changed files with 177 additions and 45 deletions

View File

@@ -70,7 +70,8 @@ public final class MultiplayerChannel {
property = "type", property = "type",
subtypes = { subtypes = {
@JsonSubtype(clazz = JoinResponse.class, name = "join"), @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 { 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 { public static class CatoClient extends Event {
private final String username; private final String username;

View File

@@ -26,6 +26,7 @@ import java.io.*;
import java.net.ConnectException; import java.net.ConnectException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.Socket; import java.net.Socket;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.Logging.LOG;
@@ -38,6 +39,7 @@ public class MultiplayerClient extends Thread {
private final EventManager<ConnectedEvent> onConnected = new EventManager<>(); private final EventManager<ConnectedEvent> onConnected = new EventManager<>();
private final EventManager<Event> onDisconnected = new EventManager<>(); private final EventManager<Event> onDisconnected = new EventManager<>();
private final EventManager<Event> onKicked = new EventManager<>();
public MultiplayerClient(String id, int port) { public MultiplayerClient(String id, int port) {
this.id = id; this.id = id;
@@ -63,6 +65,10 @@ public class MultiplayerClient extends Thread {
return onDisconnected; return onDisconnected;
} }
public EventManager<Event> onKicked() {
return onDisconnected;
}
@Override @Override
public void run() { public void run() {
LOG.info("Connecting to 127.0.0.1:" + port); 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()))) { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) {
LOG.info("Connected to 127.0.0.1:" + port); 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.write(JsonUtils.UGLY_GSON.toJson(new JoinRequest(MultiplayerManager.CATO_VERSION, id)));
writer.newLine(); writer.newLine();
writer.flush(); writer.flush();
LOG.fine("Sent join request with id=" + id); LOG.fine("Sent join request with id=" + id);
String line = reader.readLine(); String line;
if (line == null) { while ((line = reader.readLine()) != null) {
return; if (isInterrupted()) {
}
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");
return; 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) { } catch (ConnectException e) {
LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)"); LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)");

View File

@@ -48,6 +48,7 @@ import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -146,6 +147,9 @@ public final class MultiplayerManager {
future.completeExceptionally(e); future.completeExceptionally(e);
} }
}); });
client.onKicked().register(kickedEvent -> {
future.completeExceptionally(new CancellationException());
});
client.start(); client.start();
}); });

View File

@@ -28,7 +28,6 @@ import javafx.collections.ObservableList;
import javafx.scene.control.Control; import javafx.scene.control.Control;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.Skin; import javafx.scene.control.Skin;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Schedulers;
@@ -41,11 +40,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.logging.Level; import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.config; 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.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n; 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(); int gamePort = result.getAd();
try { try {
MultiplayerManager.CatoSession session = MultiplayerManager.createSession(config().getMultiplayerToken(), result.getMotd(), gamePort); MultiplayerManager.CatoSession session = MultiplayerManager.createSession(config().getMultiplayerToken(), result.getMotd(), gamePort);
session.getServer().onClientAdding().register(event -> {
});
session.getServer().onClientAdded().register(event -> { session.getServer().onClientAdded().register(event -> {
runInFX(() -> { runInFX(() -> {
clients.add(event); clients.add(event);
@@ -263,19 +265,37 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware
.thenAcceptAsync(session -> { .thenAcceptAsync(session -> {
initCatoSession(session); initCatoSession(session);
AtomicBoolean kicked = new AtomicBoolean();
session.getClient().onDisconnected().register(() -> { session.getClient().onDisconnected().register(() -> {
runInFX(() -> { runInFX(() -> {
stopCatoSession(); 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()); gamePort.set(session.getClient().getGamePort());
setMultiplayerState(MultiplayerManager.State.SLAVE); setMultiplayerState(MultiplayerManager.State.SLAVE);
resolve.run(); resolve.run();
}, Platform::runLater).exceptionally(throwable -> { }, Platform::runLater)
LOG.log(Level.WARNING, "Failed to join sessoin"); .exceptionally(throwable -> {
reject.accept(i18n("multiplayer.session.error")); 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; return null;
}); });
} catch (MultiplayerManager.IncompatibleCatoVersionException e) { } 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()))); .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() { public void closeRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) { if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready"); throw new IllegalStateException("CatoSession not ready");

View File

@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.SVG;
@@ -315,12 +316,18 @@ public class MultiplayerPageSkin extends SkinBase<MultiplayerPage> {
} }
} }
private static class ClientItem extends StackPane { private class ClientItem extends StackPane {
ClientItem(MultiplayerChannel.CatoClient client) { ClientItem(MultiplayerChannel.CatoClient client) {
BorderPane pane = new BorderPane(); BorderPane pane = new BorderPane();
pane.setPadding(new Insets(8)); pane.setPadding(new Insets(8));
pane.setLeft(new Label(client.getUsername())); 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); RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container); getChildren().setAll(container);
getStyleClass().add("md-list-cell"); getStyleClass().add("md-list-cell");

View File

@@ -20,12 +20,17 @@ package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException; import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.FutureCallback;
import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*; import java.io.*;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; 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 java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
@@ -35,9 +40,12 @@ public class MultiplayerServer extends Thread {
private ServerSocket socket; private ServerSocket socket;
private final int gamePort; private final int gamePort;
private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>(); private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>(); private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>();
private final EventManager<Event> onKeepAlive = new EventManager<>();
private final Map<String, Client> clients = new ConcurrentHashMap<>();
private final Map<String, Client> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(int gamePort) { public MultiplayerServer(int gamePort) {
this.gamePort = gamePort; this.gamePort = gamePort;
@@ -46,6 +54,10 @@ public class MultiplayerServer extends Thread {
setDaemon(true); setDaemon(true);
} }
public void setOnClientAdding(FutureCallback<CatoClient> callback) {
onClientAdding = callback;
}
public EventManager<MultiplayerChannel.CatoClient> onClientAdded() { public EventManager<MultiplayerChannel.CatoClient> onClientAdded() {
return onClientAdded; return onClientAdded;
} }
@@ -54,10 +66,6 @@ public class MultiplayerServer extends Thread {
return onClientDisconnected; return onClientDisconnected;
} }
public EventManager<Event> onKeepAlive() {
return onKeepAlive;
}
public void startServer() throws IOException { public void startServer() throws IOException {
startServer(0); 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) { private void handleClient(Socket targetSocket) {
String address = targetSocket.getRemoteSocketAddress().toString();
String clientName = null; String clientName = null;
LOG.info("Accepted client " + targetSocket.getRemoteSocketAddress()); LOG.info("Accepted client " + address);
try (Socket clientSocket = targetSocket; try (Socket clientSocket = targetSocket;
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) {
clientSocket.setKeepAlive(true);
Client client = new Client(clientSocket, writer);
clients.put(address, client);
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
if (isInterrupted()) { if (isInterrupted()) {
@@ -110,19 +137,35 @@ public class MultiplayerServer extends Thread {
if (request instanceof JoinRequest) { if (request instanceof JoinRequest) {
JoinRequest joinRequest = (JoinRequest) request; JoinRequest joinRequest = (JoinRequest) request;
LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername()); 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(); 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 { } else {
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line); LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
} }
@@ -135,6 +178,25 @@ public class MultiplayerServer extends Thread {
if (clientName != null) { if (clientName != null) {
onClientDisconnected.fireEvent(new CatoClient(this, clientName)); 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();
} }
} }
} }

View File

@@ -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=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.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.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.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.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.members=Room Members
multiplayer.session.quit=Quit Room multiplayer.session.quit=Quit Room
multiplayer.session.quit.warning=After quiting room, you will lost the connection with the server. Continue? multiplayer.session.quit.warning=After quiting room, you will lost the connection with the server. Continue?

View File

@@ -643,8 +643,10 @@ multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要
multiplayer.session.join.invitation_code=邀請碼 multiplayer.session.join.invitation_code=邀請碼
multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼 multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼
multiplayer.session.join.invitation_code.version=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。 multiplayer.session.join.invitation_code.version=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。
multiplayer.session.join.kicked=你已經被房主踢出房間,你將與房間失去連接。
multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。 multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。
multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。 multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。
multiplayer.session.join.rejected=你被房主拒絕連接。
multiplayer.session.members=房間成員 multiplayer.session.members=房間成員
multiplayer.session.quit=退出房間 multiplayer.session.quit=退出房間
multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續? multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續?

View File

@@ -643,8 +643,10 @@ multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要
multiplayer.session.join.invitation_code=邀请码 multiplayer.session.join.invitation_code=邀请码
multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码 multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码
multiplayer.session.join.invitation_code.version=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。 multiplayer.session.join.invitation_code.version=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。
multiplayer.session.join.kicked=你已经被房主踢出房间,你将与房间失去连接。
multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。 multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。
multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。 multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。
multiplayer.session.join.rejected=你被房主拒绝连接。
multiplayer.session.members=房间成员 multiplayer.session.members=房间成员
multiplayer.session.quit=退出房间 multiplayer.session.quit=退出房间
multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续? multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?