feat(multiplayer): relay mode.

This commit is contained in:
huanghongxun
2021-10-04 00:38:05 +08:00
parent e934d6c983
commit 8748b8c277
14 changed files with 193 additions and 48 deletions

View File

@@ -21,10 +21,7 @@ import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
@@ -73,6 +70,8 @@ public class GlobalConfig implements Cloneable, Observable {
private StringProperty multiplayerToken = new SimpleStringProperty();
private BooleanProperty multiplayerRelay = new SimpleBooleanProperty();
private IntegerProperty multiplayerAgreementVersion = new SimpleIntegerProperty(0);
private final Map<String, Object> unknownFields = new HashMap<>();
@@ -114,6 +113,18 @@ public class GlobalConfig implements Cloneable, Observable {
this.agreementVersion.set(agreementVersion);
}
public boolean isMultiplayerRelay() {
return multiplayerRelay.get();
}
public BooleanProperty multiplayerRelayProperty() {
return multiplayerRelay;
}
public void setMultiplayerRelay(boolean multiplayerRelay) {
this.multiplayerRelay.set(multiplayerRelay);
}
public int getMultiplayerAgreementVersion() {
return multiplayerAgreementVersion.get();
}
@@ -154,6 +165,7 @@ public class GlobalConfig implements Cloneable, Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("multiplayerToken", context.serialize(src.getMultiplayerToken()));
jsonObject.add("multiplayerRelay", context.serialize(src.isMultiplayerRelay()));
jsonObject.add("multiplayerAgreementVersion", context.serialize(src.getMultiplayerAgreementVersion()));
for (Map.Entry<String, Object> entry : src.unknownFields.entrySet()) {
jsonObject.add(entry.getKey(), context.serialize(entry.getValue()));
@@ -171,6 +183,7 @@ public class GlobalConfig implements Cloneable, Observable {
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setMultiplayerToken(Optional.ofNullable(obj.get("multiplayerToken")).map(JsonElement::getAsString).orElse(null));
config.setMultiplayerRelay(Optional.ofNullable(obj.get("multiplayerRelay")).map(JsonElement::getAsBoolean).orElse(false));
config.setMultiplayerAgreementVersion(Optional.ofNullable(obj.get("multiplayerAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {

View File

@@ -33,10 +33,13 @@ import java.util.UUID;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
public class HMCLAccounts {
public final class HMCLAccounts {
private static final ObjectProperty<HMCLAccount> account = new SimpleObjectProperty<>();
private HMCLAccounts() {
}
public static HMCLAccount getAccount() {
return account.get();
}

View File

@@ -27,10 +27,13 @@ import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
public class OptionToggleButton extends StackPane {
private final StringProperty title = new SimpleStringProperty();
private final StringProperty subtitle = new SimpleStringProperty();
private final BooleanProperty selected = new SimpleBooleanProperty();
public OptionToggleButton() {
@@ -41,11 +44,15 @@ public class OptionToggleButton extends StackPane {
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
Label label = new Label();
label.setMouseTransparent(true);
label.textProperty().bind(title);
pane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
VBox left = new VBox();
Label titleLabel = new Label();
titleLabel.setMouseTransparent(true);
titleLabel.textProperty().bind(title);
Label subtitleLabel = new Label();
subtitleLabel.setMouseTransparent(true);
subtitleLabel.textProperty().bind(subtitle);
pane.setLeft(left);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
JFXToggleButton toggleButton = new JFXToggleButton();
pane.setRight(toggleButton);
@@ -56,6 +63,14 @@ public class OptionToggleButton extends StackPane {
container.setOnMouseClicked(e -> {
toggleButton.setSelected(!toggleButton.isSelected());
});
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
left.getChildren().setAll(titleLabel, subtitleLabel);
} else {
left.getChildren().setAll(titleLabel);
}
});
}
public String getTitle() {
@@ -70,6 +85,18 @@ public class OptionToggleButton extends StackPane {
this.title.set(title);
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public boolean isSelected() {
return selected.get();
}

View File

@@ -183,7 +183,7 @@ public class FeedbackPage extends VBox implements PageAware {
}
HttpRequest req = HttpRequest.GET(NetworkUtils.withQuery("https://hmcl.huangyuhui.net/api/feedback", query));
if (account != null) {
req.authorization("Bearer", HMCLAccounts.getAccount().getIdToken())
req.authorization("Bearer", HMCLAccounts.getAccount().getIdToken())
.header("Authorization-Provider", HMCLAccounts.getAccount().getProvider());
}
return req.<List<FeedbackResponse>>getJson(new TypeToken<List<FeedbackResponse>>(){}.getType());

View File

@@ -53,7 +53,6 @@ import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.newImage;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;

View File

@@ -73,19 +73,22 @@ public class MultiplayerClient extends Thread {
public void run() {
LOG.info("Connecting to 127.0.0.1:" + port);
for (int i = 0; i < 5; i++) {
KeepAliveThread keepAliveThread = null;
try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) {
MultiplayerServer.Endpoint endpoint = new MultiplayerServer.Endpoint(socket, writer);
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);
keepAliveThread = new KeepAliveThread(endpoint);
keepAliveThread.start();
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
@@ -106,6 +109,7 @@ public class MultiplayerClient extends Thread {
onKicked.fireEvent(new Event(this));
LOG.fine("Kicked by the server");
} else if (response instanceof KeepAliveResponse) {
} else {
LOG.log(Level.WARNING, "Unrecognized packet from server:" + line);
}
@@ -121,12 +125,42 @@ public class MultiplayerClient extends Thread {
continue;
} catch (IOException | JsonParseException e) {
e.printStackTrace();
} finally {
if (keepAliveThread != null) {
keepAliveThread.interrupt();
}
}
}
LOG.info("Lost connection to 127.0.0.1:" + port);
onDisconnected.fireEvent(new Event(this));
}
private static class KeepAliveThread extends Thread {
private final MultiplayerServer.Endpoint endpoint;
public KeepAliveThread(MultiplayerServer.Endpoint endpoint) {
this.endpoint = endpoint;
}
@Override
public void run() {
while (!isInterrupted()) {
try {
endpoint.write(new KeepAliveRequest(System.currentTimeMillis()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send keep alive packet", e);
break;
}
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
break;
}
}
}
}
public static class ConnectedEvent extends Event {
private final int port;

View File

@@ -44,10 +44,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
@@ -67,7 +64,6 @@ public final class MultiplayerManager {
private static final String REMOTE_ADDRESS = "127.0.0.1";
private static final String LOCAL_ADDRESS = "0.0.0.0";
private static final String MODE = "p2p";
private MultiplayerManager() {
}
@@ -89,7 +85,7 @@ public final class MultiplayerManager {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH);
}
public static CompletableFuture<CatoSession> joinSession(String token, String version, String sessionName, String peer, int remotePort, int localPort) throws IncompatibleCatoVersionException {
public static CompletableFuture<CatoSession> joinSession(String token, String version, String sessionName, String peer, Mode mode, int remotePort, int localPort) throws IncompatibleCatoVersionException {
if (!CATO_VERSION.equals(version)) {
throw new IncompatibleCatoVersionException(version, CATO_VERSION);
}
@@ -100,12 +96,16 @@ public final class MultiplayerManager {
}
return CompletableFuture.completedFuture(null).thenComposeAsync(unused -> {
if (!isPortAvailable(3478)) {
throw new CatoAlreadyStartedException();
}
String[] commands = new String[]{exe.toString(),
"--token", StringUtils.isBlank(token) ? "new" : token,
"--id", peer,
"--local", String.format("%s:%d", LOCAL_ADDRESS, localPort),
"--remote", String.format("%s:%d", REMOTE_ADDRESS, remotePort),
"--mode", MODE};
"--mode", mode.getName()};
Process process;
try {
process = new ProcessBuilder()
@@ -137,18 +137,21 @@ public final class MultiplayerManager {
client.onConnected().register(connectedEvent -> {
try {
int port = findAvailablePort();
String command = String.format("net add %s %s:%d %s:%d %s\n", peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), MODE);
String command = String.format("net add %s %s:%d %s:%d %s\n", peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode.getName());
LOG.info("Invoking cato: " + command);
client.setGamePort(port);
writer.write(command);
writer.newLine();
writer.flush();
future.complete(session);
} catch (IOException e) {
future.completeExceptionally(e);
session.stop();
}
});
client.onKicked().register(kickedEvent -> {
future.completeExceptionally(new CancellationException());
session.stop();
});
client.start();
});
@@ -163,13 +166,16 @@ public final class MultiplayerManager {
throw new IllegalStateException("Cato file not found");
}
if (!isPortAvailable(3478)) {
throw new CatoAlreadyStartedException();
}
MultiplayerServer server = new MultiplayerServer(gamePort);
server.startServer();
String[] commands = new String[]{exe.toString(),
"--token", StringUtils.isBlank(token) ? "new" : token,
"--allows", String.format("%s:%d/%s:%d", REMOTE_ADDRESS, server.getPort(), REMOTE_ADDRESS, gamePort),
"--mode", MODE};
"--allows", String.format("%s:%d/%s:%d", REMOTE_ADDRESS, server.getPort(), REMOTE_ADDRESS, gamePort)};
Process process = new ProcessBuilder()
.command(commands)
.start();
@@ -191,6 +197,14 @@ public final class MultiplayerManager {
}
}
public static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
public static String getCatoPath() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
@@ -420,4 +434,16 @@ public final class MultiplayerManager {
return actual;
}
}
public enum Mode {
P2P,
RELAY;
String getName() {
return name().toLowerCase(Locale.ROOT);
}
}
public static class CatoAlreadyStartedException extends RuntimeException {
}
}

View File

@@ -227,6 +227,9 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware
});
});
initCatoSession(session);
} catch (MultiplayerManager.CatoAlreadyStartedException e) {
LOG.log(Level.WARNING, "Cato already started", e);
reject.accept(i18n("multiplayer.session.error.already_started"));
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to create session", e);
reject.accept(i18n("multiplayer.session.create.error"));
@@ -264,7 +267,16 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware
}
try {
MultiplayerManager.joinSession(globalConfig().getMultiplayerToken(), invitation.getVersion(), invitation.getSessionName(), invitation.getId(), invitation.getChannelPort(), localPort)
MultiplayerManager.joinSession(
globalConfig().getMultiplayerToken(),
invitation.getVersion(),
invitation.getSessionName(),
invitation.getId(),
globalConfig().isMultiplayerRelay() && StringUtils.isNotBlank(globalConfig().getMultiplayerToken())
? MultiplayerManager.Mode.RELAY
: MultiplayerManager.Mode.P2P,
invitation.getChannelPort(),
localPort)
.thenAcceptAsync(session -> {
initCatoSession(session);
@@ -295,9 +307,13 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware
LOG.info("Connection rejected by the server");
reject.accept(i18n("multiplayer.session.join.rejected"));
return null;
} else if (throwable instanceof MultiplayerManager.CatoAlreadyStartedException) {
LOG.info("Cato already started");
reject.accept(i18n("multiplayer.session.error.already_started"));
return null;
} else {
LOG.log(Level.WARNING, "Failed to join sessoin");
reject.accept(i18n("multiplayer.session.error"));
reject.accept(i18n("multiplayer.session.join.error"));
}
return null;
});

View File

@@ -265,6 +265,12 @@ public class MultiplayerPageSkin extends SkinBase<MultiplayerPage> {
gridPane.addRow(0, new Label(i18n("multiplayer.session.create.token")), tokenField, applyLink);
OptionToggleButton relay = new OptionToggleButton();
relay.disableProperty().bind(tokenField.textProperty().isEmpty());
relay.selectedProperty().bindBidirectional(globalConfig().multiplayerRelayProperty());
relay.setTitle(i18n("multiplayer.relay"));
relay.setSubtitle(i18n("multiplayer.relay.hint"));
HBox pane = new HBox();
pane.setAlignment(Pos.CENTER_LEFT);
@@ -280,7 +286,7 @@ public class MultiplayerPageSkin extends SkinBase<MultiplayerPage> {
placeholder,
FXUtils.segmentToTextFlow(i18n("multiplayer.powered_by"), Controllers::onHyperlinkAction));
thanksPane.getContent().addAll(gridPane, pane);
thanksPane.getContent().addAll(gridPane, relay, pane);
}
content.getChildren().setAll(

View File

@@ -18,6 +18,7 @@
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;
@@ -40,9 +41,10 @@ public class MultiplayerServer extends Thread {
private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = 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<>();
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(int gamePort) {
this.gamePort = gamePort;
@@ -63,6 +65,10 @@ public class MultiplayerServer extends Thread {
return onClientDisconnected;
}
public EventManager<Event> onKeepAlive() {
return onKeepAlive;
}
public void startServer() throws IOException {
startServer(0);
}
@@ -98,7 +104,7 @@ public class MultiplayerServer extends Thread {
}
public void kickPlayer(CatoClient player) {
Client client = nameClientMap.get(player.getUsername());
Endpoint client = nameClientMap.get(player.getUsername());
if (client == null) return;
try {
@@ -119,8 +125,8 @@ public class MultiplayerServer extends Thread {
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);
Endpoint endpoint = new Endpoint(clientSocket, writer);
clients.put(address, endpoint);
String line;
while ((line = reader.readLine()) != null) {
@@ -137,13 +143,13 @@ public class MultiplayerServer extends Thread {
clientName = joinRequest.getUsername();
CatoClient catoClient = new CatoClient(this, clientName);
nameClientMap.put(clientName, client);
nameClientMap.put(clientName, endpoint);
onClientAdded.fireEvent(catoClient);
if (onClientAdding != null) {
onClientAdding.call(catoClient, () -> {
try {
client.write(new JoinResponse(gamePort));
endpoint.write(new JoinResponse(gamePort));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send join response.", e);
try {
@@ -155,7 +161,7 @@ public class MultiplayerServer extends Thread {
}
}, msg -> {
try {
client.write(new KickResponse(msg));
endpoint.write(new KickResponse(msg));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
@@ -163,6 +169,10 @@ public class MultiplayerServer extends Thread {
}
});
}
} else if (request instanceof KeepAliveRequest) {
endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));
onKeepAlive.fireEvent(new Event(this));
} else {
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
}
@@ -181,11 +191,11 @@ public class MultiplayerServer extends Thread {
}
}
private static class Client {
public static class Endpoint {
public final Socket socket;
public final BufferedWriter writer;
public Client(Socket socket, BufferedWriter writer) {
public Endpoint(Socket socket, BufferedWriter writer) {
this.socket = socket;
this.writer = writer;
}

View File

@@ -624,15 +624,17 @@ multiplayer.nat.type.symmetric_udp_firewall=Bad (Symmetric with UDP Firewall)
multiplayer.nat.type.unknown=Unknown
multiplayer.powered_by=Multiplayer service is provided by <a href="https://noin.cn">noin.cn</a>. <a href="https://noin.cn/agreement">EULA</a>
multiplayer.report=Report
multiplayer.relay=Relay Mode
multiplayer.relay.hint=For users who has a static token, and whose tested NAT type is bad-symmetric
multiplayer.session=Room
multiplayer.session.name.format=%1$s's Room
multiplayer.session.name.motd=HMCL Multiplayer Session - %s
multiplayer.session.close=Close Room
multiplayer.session.close.warning=After closing room, all players joined the room will lost connection. Continue?
multiplayer.session.copy_room_code=Copy Invitation Code
multiplayer.session.create=Create Room
multiplayer.session.create.error=Failed to create multiplayer room.
multiplayer.session.create.hint=Before creating multiplayer room, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below.
multiplayer.session.create=Create Session
multiplayer.session.create.error=Failed to create multiplayer session.
multiplayer.session.create.hint=Before creating multiplayer session, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below.
multiplayer.session.create.join=Connection request
multiplayer.session.create.join.prompt=Player %s wants to join the multiplayer session. Accept?
multiplayer.session.create.members=Members
@@ -642,14 +644,16 @@ multiplayer.session.create.name=Session Name
multiplayer.session.create.port=Port
multiplayer.session.create.port.error=Cannot detect game port, you must click "Open LAN Server" in game to enable multiplayer functionality.
multiplayer.session.create.token=Token
multiplayer.session.create.token.apply=Apple for static token
multiplayer.session.create.token.apply=Apply for static token
multiplayer.session.create.token.prompt=Default randomized. You can apply for your own token at noin.cn (Chinese website).
multiplayer.session.error.already_started=Cato service has already started locally. Please check whether there are other multiplayer services served or not. Or you can kill cato process in task manager.
multiplayer.session.expired=Multiplayer session has expired. You should re-create or re-join a room to continue.
multiplayer.session.hint=You must click "Open LAN Server" in game in order to enable multiplayer functionality.
multiplayer.session.join=Join Room
multiplayer.session.join.hint=You must obtain the invitation code from the gamer who has already created a multiplayer room.
multiplayer.session.join=Join Session
multiplayer.session.join.error=Failed to join multiplayer session
multiplayer.session.join.hint=You must obtain the invitation code from the gamer who has already created a multiplayer session.
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 session.
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.

View File

@@ -624,6 +624,8 @@ multiplayer.nat.type.symmetric_udp_firewall=差(對稱型+防火牆)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人聯機服務由 這裡 (<a href="https://noin.cn">noin.cn</a>) 提供。<a href="https://noin.cn/agreement">用戶協議與免責聲明</a>
multiplayer.report=違法違規檢舉
multiplayer.relay=中繼模式
multiplayer.relay.hint=NAT 網路類型為差-對稱型的用戶,若無法聯機,申請靜態 Token 後可以啟用本選項
multiplayer.session=房間
multiplayer.session.name.format=%1$s 的房間
multiplayer.session.name.motd=HMCL 多人聯機房間 - %s
@@ -644,8 +646,10 @@ multiplayer.session.create.port.error=無法檢測遊戲埠號,你必須先啟
multiplayer.session.create.token=Token
multiplayer.session.create.token.apply=申請靜態 Token
multiplayer.session.create.token.prompt=預設為臨時 Token。你可以在 noin.cn 上申請靜態 Token 並填寫至此處
multiplayer.session.error.already_started=本地已經開啟 cato 服務,請檢查是否有其他 HMCL 正在運行聯機服務。或者你可以在任務管理器裡殺掉 cato 進程以繼續。
multiplayer.session.expired=聯機會話連續使用時間超過了 3 小時,你需要重新創建/加入房間以繼續聯機。
multiplayer.session.join=加入房間
multiplayer.session.join.error=加入房間失敗
multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要邀請碼以便加入多人聯機房間
multiplayer.session.join.invitation_code=邀請碼
multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼

View File

@@ -623,6 +623,8 @@ multiplayer.nat.type.symmetric=差(对称型)
multiplayer.nat.type.symmetric_udp_firewall=差(对称型+防火墙)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人联机服务由 这里 (<a href="https://noin.cn">noin.cn</a>) 提供。<a href="https://noin.cn/agreement">用户协议与免责声明</a>
multiplayer.relay=中继模式
multiplayer.relay.hint=NAT 网络类型为差-对称型的用户,若无法联机,申请静态 Token 后可以启用本选项
multiplayer.report=违法违规举报
multiplayer.session=房间
multiplayer.session.name.format=%1$s 的房间
@@ -644,8 +646,10 @@ multiplayer.session.create.port.error=无法检测游戏端口号,你必须先
multiplayer.session.create.token=Token
multiplayer.session.create.token.apply=申请静态 Token
multiplayer.session.create.token.prompt=默认为临时 Token。你可以在 noin.cn 上申请静态 Token 并填写至此处
multiplayer.session.error.already_started=本地已经开启 cato 服务,请检查是否有其他 HMCL 正在运行联机服务。或者你可以在任务管理器里杀掉 cato 进程以继续。
multiplayer.session.expired=联机会话连续使用时间超过了 3 小时,你需要重新创建/加入房间以继续联机。
multiplayer.session.join=加入房间
multiplayer.session.join.error=加入房间失败
multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要邀请码以便加入多人联机房间
multiplayer.session.join.invitation_code=邀请码
multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码

View File

@@ -18,13 +18,13 @@
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.util.Logging;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
public class MultiplayerClientServerTest {
@Test
// @Ignore
@Ignore
public void startServer() throws Exception {
Logging.initForTest();
MultiplayerServer server = new MultiplayerServer(1000);
@@ -33,8 +33,7 @@ public class MultiplayerClientServerTest {
MultiplayerClient client = new MultiplayerClient("username", 44444);
client.start();
server.onClientAdded().register(event -> {
Assert.assertEquals("username", event.getUsername());
server.onKeepAlive().register(event -> {
client.interrupt();
server.interrupt();
});