From 5dd43bcd40e0a417d4af303200bf0c58f512a541 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Fri, 7 Aug 2026 14:14:08 +0800
Subject: [PATCH 1/9] feat(network): migrate packet builders to core packet
layer
---
.../network/core/packet/UniversalPacket.java | 423 ++++++++++++++++++
.../UniversalPacketCompatibilityTest.java | 104 +++++
2 files changed, 527 insertions(+)
create mode 100644 src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
create mode 100644 src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
diff --git a/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
new file mode 100644
index 0000000..8be0557
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
@@ -0,0 +1,423 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.packet;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.game.CheckSumList;
+import cn.rukkit.game.GameActions;
+import cn.rukkit.game.PingType;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.game.map.CustomMapLoader;
+import cn.rukkit.game.mod.Mod.ModUnit;
+import cn.rukkit.game.unit.InternalUnit;
+import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.util.GameUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Random;
+
+/**
+ * Packet builders for the existing protocol.
+ *
+ * This class is the first compatibility step in the network migration. It
+ * deliberately keeps the old room and command parameter types so the old
+ * runtime can be moved to the new packet package without changing wire
+ * behavior. The room overloads can be changed to {@code ServerRoom} after the
+ * room model migration is complete.
+ */
+public final class UniversalPacket {
+ private UniversalPacket() {
+ }
+
+ // Compatibility aliases for callers that used constants from the old Packet class.
+ public static final int PACKET_REGISTER_CONNECTION = PacketType.REGISTER_CONNECTION;
+ public static final int PACKET_TEAM_LIST = PacketType.TEAM_LIST;
+ public static final int PACKET_HEART_BEAT = PacketType.HEART_BEAT;
+ public static final int PACKET_SEND_CHAT = PacketType.SEND_CHAT;
+ public static final int PACKET_SERVER_INFO = PacketType.SERVER_INFO;
+ public static final int PACKET_START_GAME = PacketType.START_GAME;
+ public static final int PACKET_QUESTION = PacketType.QUESTION;
+ public static final int PACKET_QUESTION_RESPONCE = PacketType.QUESTION_RESPONCE;
+ public static final int PACKET_PREREGISTER_CONNECTION = PacketType.PREREGISTER_CONNECTION;
+ public static final int PACKET_HEART_BEAT_RESPONSE = PacketType.HEART_BEAT_RESPONSE;
+ public static final int PACKET_ADD_CHAT = PacketType.ADD_CHAT;
+ public static final int PACKET_PLAYER_INFO = PacketType.PLAYER_INFO;
+ public static final int PACKET_DISCONNECT = PacketType.DISCONNECT;
+ public static final int PACKET_RANDY = PacketType.READY;
+ public static final int PACKET_ADD_GAMECOMMAND = PacketType.ADD_GAMECOMMAND;
+ public static final int PACKET_TICK = PacketType.TICK;
+ public static final int PACKET_SYNC_CHECKSUM = PacketType.SYNC_CHECKSUM;
+ public static final int PACKET_SYNC_CHECKSUM_RESPONCE = PacketType.SYNC_CHECKSUM_RESPONCE;
+ public static final int PACKET_SYNC = PacketType.SYNC;
+
+ public static Packet chat(String from, String msg, int team) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString(msg);
+ output.writeByte(3);
+ output.writeBoolean(true);
+ output.writeString(from);
+ output.writeInt(team);
+ output.writeInt(team);
+ return output.createPacket(PacketType.SEND_CHAT);
+ }
+
+ public static Packet ping() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeLong(new Random().nextLong());
+ output.writeByte(0);
+ return output.createPacket(PacketType.HEART_BEAT);
+ }
+
+ public static Packet preRegister() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString("cn.rukkit");
+ output.writeInt(1);
+ output.writeInt(176);
+ output.writeInt(176);
+ output.writeString("cn.rukkit");
+ output.writeString(Rukkit.getConfig().UUID);
+ output.writeInt(114514);
+ output.writeInt(176);
+ return output.createPacket(PacketType.REGISTER_CONNECTION);
+ }
+
+ public static Packet gameCommand(int tick, GameCommand command) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(tick);
+ output.writeInt(1);
+ output.startBlock("c", false);
+ output.write(command.arr);
+ output.endBlock();
+ return output.createPacket(PacketType.TICK);
+ }
+
+ public static Packet emptyCommand(int tick) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(tick);
+ output.writeInt(0);
+ return output.createPacket(PacketType.TICK);
+ }
+
+ public static Packet gameStart() throws IOException {
+ return startGame();
+ }
+
+ public static Packet startGame() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ if (Rukkit.getRoundConfig().mapType == 0) {
+ output.writeInt(0);
+ output.writeString("maps/skirmish/" + Rukkit.getRoundConfig().mapName + ".tmx");
+ } else if (Rukkit.getRoundConfig().mapType == 1) {
+ output.writeInt(1);
+ output.writeFile(CustomMapLoader.getStreamByName(Rukkit.getRoundConfig().mapName + ".tmx"));
+ output.writeString(Rukkit.getRoundConfig().mapName + ".tmx");
+ }
+ output.writeBoolean(false);
+ return output.createPacket(PacketType.START_GAME);
+ }
+
+ public static Packet serverInfo(RoundConfig config) throws IOException {
+ return serverInfo(config, false, Rukkit.getModManager().fetchAllEnabledModUnits());
+ }
+
+ public static Packet serverInfo(RoundConfig config, Boolean isAdmin) throws IOException {
+ return serverInfo(config, isAdmin, Rukkit.getModManager().fetchAllEnabledModUnits());
+ }
+
+ public static Packet serverInfo(RoundConfig config, boolean isAdmin, ArrayList units)
+ throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString("com.corrodinggames.rts");
+ output.writeInt(176);
+ output.writeInt(config.mapType);
+ output.writeString(config.mapName);
+ output.writeInt(GameUtils.getMoneyFormat(config.credits));
+ output.writeInt(config.fogType);
+ output.writeBoolean(true);
+ output.writeInt(1);
+ output.writeByte(4);
+ output.writeBoolean(false);
+ output.writeBoolean(isAdmin);
+ output.writeInt(Rukkit.getConfig().maxUnitsPerPlayer);
+ output.writeInt(Rukkit.getConfig().maxUnitsPerPlayer);
+ output.writeInt(config.startingUnits);
+ output.writeFloat(config.income);
+ output.writeBoolean(config.disableNuke);
+ output.writeBoolean(false);
+ output.writeBoolean(true);
+
+ output.startBlock("customUnits", false);
+ output.writeInt(1);
+ output.writeInt(units.size());
+ for (ModUnit unit : units) {
+ output.writeString(unit.getUnitName());
+ output.writeInt(unit.getUnitId());
+ output.writeBoolean(true);
+ if ("default".equals(unit.getModName())) {
+ output.writeBoolean(false);
+ } else {
+ output.writeBoolean(true);
+ output.writeString(unit.getModName());
+ }
+ output.writeLong(0);
+ output.writeLong(0);
+ }
+ output.endBlock();
+
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ return output.createPacket(PacketType.SERVER_INFO);
+ }
+
+ public static Packet sandSave() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString("");
+ return output.createPacket(PacketType.KICK);
+ }
+
+ public static Packet kick(String reason) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString(reason);
+ return output.createPacket(PacketType.KICK);
+ }
+
+ public static Packet sendSave(int step, byte[] save, boolean isPullSave) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(step);
+ output.writeInt(step / 10);
+ output.writeFloat(1.0f);
+ output.writeFloat(1.0f);
+ output.writeBoolean(isPullSave);
+ output.writeBoolean(false);
+ output.write(save);
+ return output.createPacket(PacketType.SYNC);
+ }
+
+ public static Packet sendSave(NetworkRoom room, byte[] save, boolean isPullSave) throws IOException {
+ return sendSave(room.getCurrentStep(), save, isPullSave);
+ }
+
+ public static Packet sendPullSave(int step) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(step);
+ output.writeInt(step / 10);
+ output.writeFloat(1.0f);
+ output.writeFloat(1.0f);
+ output.writeBoolean(true);
+ output.writeBoolean(false);
+ output.startBlock("gameSave", false);
+ output.write(Rukkit.getDefaultSave().arr);
+ output.endBlock();
+ return output.createPacket(PacketType.SYNC);
+ }
+
+ public static Packet sendPullSave(int step, byte[] save) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(step);
+ output.writeInt(step / 10);
+ output.writeFloat(1.0f);
+ output.writeFloat(1.0f);
+ output.writeBoolean(true);
+ output.writeBoolean(false);
+ output.startBlock("gameSave", false);
+ output.write(save);
+ output.endBlock();
+ return output.createPacket(PacketType.SYNC);
+ }
+
+ public static Packet sendPullSave(NetworkRoom room) throws IOException {
+ return sendPullSave(room.getCurrentStep());
+ }
+
+ public static Packet syncCheckSum(int step) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(step);
+ output.writeLong(0);
+ output.writeInt(15);
+ for (int i = 0; i < 15; i++) {
+ output.writeLong(0);
+ }
+ return output.createPacket(PacketType.SYNC_CHECKSUM);
+ }
+
+ public static Packet syncCheckSum(int step, CheckSumList checkSumList) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(step);
+ output.writeLong(0);
+ output.writeInt(checkSumList.getCheckList().size());
+ for (CheckSumList.ChecksumItem item : checkSumList.getCheckList()) {
+ output.writeLong(item.prefix);
+ }
+ return output.createPacket(PacketType.SYNC_CHECKSUM);
+ }
+
+ public static Packet syncCheckSum(NetworkRoom room) throws IOException {
+ return syncCheckSum(room.getCurrentStep());
+ }
+
+ public static Packet gamePing(int step, int index, PingType type, float x, float y) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(step);
+ output.writeInt(1);
+ output.startBlock("c", false);
+ output.writeByte(index);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(-1);
+ output.writeInt(-1);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeBoolean(true);
+ output.writeByte(0);
+ output.writeBoolean(true);
+ output.writeFloat(x);
+ output.writeFloat(y);
+ output.writeLong(-1);
+ output.writeString("c_6_" + type.toString());
+ output.writeBoolean(false);
+ output.stream.writeShort(0);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.endBlock();
+ return output.createPacket(PacketType.TICK);
+ }
+
+ public static Packet gamePing(NetworkRoom room, int index, PingType type, float x, float y)
+ throws IOException {
+ return gamePing(room.getCurrentStep(), index, type, x, y);
+ }
+
+ public static Packet gameSummon(int step, String unit, float x, float y, int team) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(step);
+ output.writeInt(1);
+ output.startBlock("c", false);
+ output.writeByte(team);
+ output.writeBoolean(true);
+ output.writeEnum(GameActions.BUILD);
+ int unitType = -2;
+ for (int i = 0; i < InternalUnit.units.length; i++) {
+ if (InternalUnit.units[i].equals(unit)) {
+ unitType = i;
+ break;
+ }
+ }
+ output.writeInt(unitType);
+ if (unitType == -2) {
+ output.writeString(unit);
+ }
+ output.writeFloat(x);
+ output.writeFloat(y);
+ output.writeLong(-1L);
+ output.writeByte(42);
+ output.writeFloat(1.0f);
+ output.writeFloat(1.0f);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(-1);
+ output.writeInt(-1);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeLong(-1);
+ output.writeString(unit);
+ output.writeBoolean(false);
+ output.stream.writeShort(0);
+ output.writeBoolean(true);
+ output.writeByte(0);
+ output.writeFloat(0);
+ output.writeFloat(0);
+ output.writeInt(5);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.endBlock();
+ return output.createPacket(PacketType.TICK);
+ }
+
+ public static Packet gameSummon(int step, String unit, float x, float y) throws IOException {
+ return gameSummon(step, unit, x, y, -1);
+ }
+
+ public static Packet gameSummon(NetworkRoom room, String unit, float x, float y) throws IOException {
+ return gameSummon(room.getCurrentStep(), unit, x, y, -1);
+ }
+
+ public static Packet gameSummon(NetworkRoom room, String unit, float x, float y, int team)
+ throws IOException {
+ return gameSummon(room.getCurrentStep(), unit, x, y, team);
+ }
+
+ public static Packet gameSurrounder(int step, int index) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(step);
+ output.writeInt(1);
+ output.startBlock("c", false);
+ output.writeByte(index);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(-1);
+ output.writeInt(-1);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeLong(-1);
+ output.writeString("-1");
+ output.writeBoolean(false);
+ output.stream.writeShort(0);
+ output.writeBoolean(true);
+ output.writeByte(0);
+ output.writeFloat(0);
+ output.writeFloat(0);
+ output.writeInt(100);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.endBlock();
+ return output.createPacket(PacketType.TICK);
+ }
+
+ public static Packet gameSurrounder(NetworkRoom room, int index) throws IOException {
+ return gameSurrounder(room.getCurrentStep(), index);
+ }
+
+ public static Packet packetQuestion(int questionId, String question) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(1);
+ output.writeInt(questionId);
+ output.writeString(question);
+ return output.createPacket(PacketType.QUESTION);
+ }
+
+ public static Packet packetReturnToBattleroom() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ return output.createPacket(PacketType.RETURN_TO_BATTLEROOM);
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java b/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
new file mode 100644
index 0000000..9a81b10
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.packet;
+
+import cn.rukkit.game.PingType;
+import cn.rukkit.network.command.GameCommand;
+import java.io.IOException;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Locks down the wire representation while packet builders move packages.
+ */
+class UniversalPacketCompatibilityTest {
+ @FunctionalInterface
+ private interface PacketFactory {
+ T create() throws IOException;
+ }
+
+ @Test
+ void chatMatchesLegacyPacket() throws IOException {
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.chat("server", "hello", -1),
+ () -> UniversalPacket.chat("server", "hello", -1));
+ }
+
+ @Test
+ void gameCommandMatchesLegacyPacket() throws IOException {
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {1, 0, 0, 0, 2, 3, 5, 8};
+
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.gameCommand(42, command),
+ () -> UniversalPacket.gameCommand(42, command));
+ }
+
+ @Test
+ void simplePacketsMatchLegacyPacket() throws IOException {
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.emptyCommand(42),
+ () -> UniversalPacket.emptyCommand(42));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.sandSave(),
+ () -> UniversalPacket.sandSave());
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.kick("unsupported"),
+ () -> UniversalPacket.kick("unsupported"));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.packetQuestion(7, "continue?"),
+ () -> UniversalPacket.packetQuestion(7, "continue?"));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.packetReturnToBattleroom(),
+ () -> UniversalPacket.packetReturnToBattleroom());
+ }
+
+ @Test
+ void saveAndChecksumPacketsMatchLegacyPacket() throws IOException {
+ byte[] save = {0, 1, 2, 3, 8, 13, 21};
+
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.sendSave(123, save, false),
+ () -> UniversalPacket.sendSave(123, save, false));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.sendSave(123, save, true),
+ () -> UniversalPacket.sendSave(123, save, true));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.syncCheckSum(123),
+ () -> UniversalPacket.syncCheckSum(123));
+ }
+
+ @Test
+ void gameActionPacketsMatchLegacyPacket() throws IOException {
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.gamePing(123, 2, PingType.attack, 12.5f, -4.25f),
+ () -> UniversalPacket.gamePing(123, 2, PingType.attack, 12.5f, -4.25f));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.gameSummon(123, "custom-unit", 12.5f, -4.25f, 2),
+ () -> UniversalPacket.gameSummon(123, "custom-unit", 12.5f, -4.25f, 2));
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.gameSurrounder(123, 2),
+ () -> UniversalPacket.gameSurrounder(123, 2));
+ }
+
+ private static void assertEquivalent(PacketFactory legacyFactory,
+ PacketFactory migratedFactory) throws IOException {
+ cn.rukkit.network.packet.Packet legacy = legacyFactory.create();
+ Packet migrated = migratedFactory.create();
+
+ assertEquals(legacy.type, migrated.type);
+ assertArrayEquals(legacy.bytes, migrated.bytes,
+ () -> "packet payload differs: legacy=" + Arrays.toString(legacy.bytes)
+ + ", migrated=" + Arrays.toString(migrated.bytes));
+ }
+}
From a74858a17b3b7ea86470d7030eb6f2f0862418dd Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Fri, 7 Aug 2026 15:13:45 +0800
Subject: [PATCH 2/9] feat(network): port master room state machine
---
.../rukkit/event/room/RoomStartGameEvent.java | 10 +
.../rukkit/event/room/RoomStopGameEvent.java | 10 +
.../java/cn/rukkit/game/NetworkPlayer.java | 50 +-
.../java/cn/rukkit/game/PlayerManager.java | 26 +-
src/main/java/cn/rukkit/game/SaveManager.java | 20 +-
.../network/core/packet/UniversalPacket.java | 31 ++
.../network/room/RoomConnectionManager.java | 145 ++++++
.../cn/rukkit/network/room/ServerRoom.java | 435 ++++++++++++++++++
.../network/room/ServerRoomConnection.java | 199 ++++++++
src/main/java/cn/rukkit/util/Vote.java | 36 +-
.../network/room/ServerRoomBehaviorTest.java | 115 +++++
11 files changed, 1058 insertions(+), 19 deletions(-)
create mode 100644 src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
create mode 100644 src/main/java/cn/rukkit/network/room/ServerRoom.java
create mode 100644 src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
create mode 100644 src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
diff --git a/src/main/java/cn/rukkit/event/room/RoomStartGameEvent.java b/src/main/java/cn/rukkit/event/room/RoomStartGameEvent.java
index 3c6f9fd..bc25762 100644
--- a/src/main/java/cn/rukkit/event/room/RoomStartGameEvent.java
+++ b/src/main/java/cn/rukkit/event/room/RoomStartGameEvent.java
@@ -12,6 +12,7 @@
import cn.rukkit.event.Event;
import cn.rukkit.event.ListenerList;
import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.room.ServerRoom;
public class RoomStartGameEvent extends Event {
private static ListenerList list = new ListenerList(RoomStartGameEvent.class);
@@ -21,12 +22,21 @@ public static ListenerList getListenerList() {
}
private NetworkRoom room;
+ private ServerRoom serverRoom;
public NetworkRoom getRoom() {
return room;
}
+ public ServerRoom getServerRoom() {
+ return serverRoom;
+ }
+
public RoomStartGameEvent(NetworkRoom room) {
this.room = room;
}
+
+ public RoomStartGameEvent(ServerRoom room) {
+ this.serverRoom = room;
+ }
}
diff --git a/src/main/java/cn/rukkit/event/room/RoomStopGameEvent.java b/src/main/java/cn/rukkit/event/room/RoomStopGameEvent.java
index dcfe287..5e1e8f5 100644
--- a/src/main/java/cn/rukkit/event/room/RoomStopGameEvent.java
+++ b/src/main/java/cn/rukkit/event/room/RoomStopGameEvent.java
@@ -12,6 +12,7 @@
import cn.rukkit.event.Event;
import cn.rukkit.event.ListenerList;
import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.room.ServerRoom;
public class RoomStopGameEvent extends Event {
private static ListenerList list = new ListenerList(RoomStopGameEvent.class);
@@ -21,12 +22,21 @@ public static ListenerList getListenerList() {
}
private NetworkRoom room;
+ private ServerRoom serverRoom;
public NetworkRoom getRoom() {
return room;
}
+ public ServerRoom getServerRoom() {
+ return serverRoom;
+ }
+
public RoomStopGameEvent(NetworkRoom room) {
this.room = room;
}
+
+ public RoomStopGameEvent(ServerRoom room) {
+ this.serverRoom = room;
+ }
}
diff --git a/src/main/java/cn/rukkit/game/NetworkPlayer.java b/src/main/java/cn/rukkit/game/NetworkPlayer.java
index 67ee3b7..d229a7f 100644
--- a/src/main/java/cn/rukkit/game/NetworkPlayer.java
+++ b/src/main/java/cn/rukkit/game/NetworkPlayer.java
@@ -10,7 +10,10 @@
package cn.rukkit.game;
import cn.rukkit.*;
import cn.rukkit.network.*;
+import cn.rukkit.network.core.packet.UniversalPacket;
import cn.rukkit.network.packet.Packet;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
import cn.rukkit.util.LangUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -41,6 +44,7 @@ public class NetworkPlayer
public int startingUnit;
private RoomConnection connection = null;
+ private ServerRoomConnection serverConnection = null;
public int ping = -1;
public boolean isAdmin = false;
@@ -56,6 +60,7 @@ public class NetworkPlayer
public CheckSumList checkList = new CheckSumList();
private NetworkRoom room;
+ private ServerRoom serverRoom;
public NetworkPlayer(RoomConnection connection) {
this.connection = connection;
@@ -63,6 +68,12 @@ public NetworkPlayer(RoomConnection connection) {
this.isEmpty = false;
}
+ public NetworkPlayer(ServerRoomConnection connection) {
+ this.serverConnection = connection;
+ this.serverRoom = connection.currectRoom;
+ this.isEmpty = false;
+ }
+
public NetworkPlayer() {
this.connection = null;
this.isEmpty = true;
@@ -71,11 +82,19 @@ public NetworkPlayer() {
public RoomConnection getConnection() {
return this.connection;
}
+
+ public ServerRoomConnection getServerConnection() {
+ return this.serverConnection;
+ }
public NetworkRoom getRoom() {
return this.room;
}
+ public ServerRoom getServerRoom() {
+ return this.serverRoom;
+ }
+
/**
* get a extraData as a object, etc..
* @param key
@@ -211,7 +230,7 @@ public void writePlayer(DataOutputStream stream, boolean simpleMode) throws IOEx
public boolean movePlayer(int index){
//If index larger then maxPlayer
if (index > Rukkit.getConfig().maxPlayer) return false;
- PlayerManager playerGroup = room.playerManager;
+ PlayerManager playerGroup = room != null ? room.playerManager : serverRoom.playerManager;
if (!playerGroup.get(index).isEmpty) {
return false;
}
@@ -231,7 +250,8 @@ public boolean moveTeam(int team){
}
public boolean giveAdmin(int index){
- NetworkPlayer player = room.playerManager.get(index);
+ PlayerManager playerManager = room != null ? room.playerManager : serverRoom.playerManager;
+ NetworkPlayer player = playerManager.get(index);
if(index < Rukkit.getConfig().maxPlayer && index >= 0 && !player.isEmpty && this.isAdmin){
player.isAdmin = true;
this.isAdmin = false;
@@ -242,16 +262,30 @@ public boolean giveAdmin(int index){
public void updateServerInfo() {
try {
- connection.handler.ctx.writeAndFlush(Packet.serverInfo(room.config, isAdmin));
+ if (serverConnection != null) {
+ serverConnection.sendPacket(UniversalPacket.serverInfo(serverRoom.config, isAdmin));
+ } else {
+ connection.handler.ctx.writeAndFlush(Packet.serverInfo(room.config, isAdmin));
+ }
} catch (IOException e) {}
}
public void sendTeamMessage(String message) {
- for (RoomConnection conn: room.connectionManager.getConnections()) {
- if (team == conn.player.team) {
- conn.sendMessage(name,
- LangUtil.getString("chat.teamMsg") + " " + message,
- playerIndex);
+ if (serverConnection != null) {
+ for (ServerRoomConnection conn : serverRoom.connectionManager.getConnections()) {
+ if (team == conn.player.team) {
+ conn.sendMessage(name,
+ LangUtil.getString("chat.teamMsg") + " " + message,
+ playerIndex);
+ }
+ }
+ } else {
+ for (RoomConnection conn: room.connectionManager.getConnections()) {
+ if (team == conn.player.team) {
+ conn.sendMessage(name,
+ LangUtil.getString("chat.teamMsg") + " " + message,
+ playerIndex);
+ }
}
}
}
diff --git a/src/main/java/cn/rukkit/game/PlayerManager.java b/src/main/java/cn/rukkit/game/PlayerManager.java
index 5c7b6ad..4e6bce2 100644
--- a/src/main/java/cn/rukkit/game/PlayerManager.java
+++ b/src/main/java/cn/rukkit/game/PlayerManager.java
@@ -10,6 +10,7 @@
package cn.rukkit.game;
import cn.rukkit.*;
import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.room.ServerRoom;
import java.util.Arrays;
//import sun.nio.ch.Net;
@@ -18,6 +19,7 @@ public class PlayerManager
{
private int max;
private NetworkRoom currentRoom;
+ private ServerRoom serverRoom;
/**
* Init player manager.
@@ -28,6 +30,12 @@ public PlayerManager(NetworkRoom room, int maxPlayer) {
currentRoom = room;
reset();
}
+
+ public PlayerManager(ServerRoom room, int maxPlayer) {
+ this.max = maxPlayer;
+ serverRoom = room;
+ reset();
+ }
private volatile NetworkPlayer[] players;
//private static Player[] inGamePlayers = new Player[ServerProperties.maxPlayer];
@@ -67,18 +75,25 @@ public void addWithTeamNoStop() {}
*/
public void remove(NetworkPlayer p){
int index = getIndex(p);
- remove(index);
+ if (currentRoom != null) {
+ remove(index);
+ } else if (index != -1) {
+ remove(index);
+ }
}
/**
* Remove player by index.
*/
public void remove(int index){
+ if (serverRoom != null && (index < 0 || index >= players.length)) {
+ return;
+ }
// if(Rukkit.getConfig().nonStopMode) {
// players[index] = new NetworkPlayer();
// return;
// }
- if(currentRoom.isGaming()){
+ if (isGaming()) {
players[index].ping = -1;
players[index].isDisconnected = true;
return;
@@ -198,4 +213,11 @@ public void clearDisconnectedPlayers() {
public int getMaxPlayer() {
return max;
}
+
+ private boolean isGaming() {
+ if (currentRoom != null) {
+ return currentRoom.isGaming();
+ }
+ return serverRoom != null && serverRoom.isGaming();
+ }
}
diff --git a/src/main/java/cn/rukkit/game/SaveManager.java b/src/main/java/cn/rukkit/game/SaveManager.java
index 3dbb439..8f517e5 100644
--- a/src/main/java/cn/rukkit/game/SaveManager.java
+++ b/src/main/java/cn/rukkit/game/SaveManager.java
@@ -17,6 +17,8 @@
import java.io.IOException;
import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.room.ServerRoom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import cn.rukkit.network.packet.Packet;
@@ -26,12 +28,18 @@ public class SaveManager {
public SaveData lastSave;
public NetworkRoom currentRoom;
+ public ServerRoom serverRoom;
private Logger log;
public SaveManager(NetworkRoom room) {
currentRoom = room;
log = LoggerFactory.getLogger("SaveManager Room #" + currentRoom.roomId);
}
+
+ public SaveManager(ServerRoom room) {
+ serverRoom = room;
+ log = LoggerFactory.getLogger("SaveManager Room #" + serverRoom.roomId);
+ }
public SaveData getDeafultSave() {
return Rukkit.getDefaultSave();
@@ -42,12 +50,20 @@ public SaveData getLastSave() {
}
public void sendDefaultSaveToAll(boolean isPullSave) throws IOException {
- currentRoom.broadcast(Packet.sendSave(currentRoom, getDeafultSave().arr, isPullSave));
+ if (serverRoom != null) {
+ serverRoom.broadcast(UniversalPacket.sendSave(serverRoom, getDeafultSave().arr, isPullSave));
+ } else {
+ currentRoom.broadcast(Packet.sendSave(currentRoom, getDeafultSave().arr, isPullSave));
+ }
}
public void sendLastSaveToAll(boolean isPullSave) throws IOException {
if (lastSave != null) {
- currentRoom.broadcast(Packet.sendSave(currentRoom, lastSave.arr,isPullSave));
+ if (serverRoom != null) {
+ serverRoom.broadcast(UniversalPacket.sendSave(serverRoom, lastSave.arr, isPullSave));
+ } else {
+ currentRoom.broadcast(Packet.sendSave(currentRoom, lastSave.arr,isPullSave));
+ }
} else {
log.error("lastSave is NULL!Ignoring sendLastSaveToAll.");
}
diff --git a/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
index 8be0557..dc9e2c8 100644
--- a/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
+++ b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
@@ -21,6 +21,7 @@
import cn.rukkit.network.NetworkRoom;
import cn.rukkit.network.command.GameCommand;
import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.room.ServerRoom;
import cn.rukkit.util.GameUtils;
import java.io.IOException;
@@ -211,6 +212,10 @@ public static Packet sendSave(NetworkRoom room, byte[] save, boolean isPullSave)
return sendSave(room.getCurrentStep(), save, isPullSave);
}
+ public static Packet sendSave(ServerRoom room, byte[] save, boolean isPullSave) throws IOException {
+ return sendSave(room.getCurrentStep(), save, isPullSave);
+ }
+
public static Packet sendPullSave(int step) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeByte(0);
@@ -245,6 +250,10 @@ public static Packet sendPullSave(NetworkRoom room) throws IOException {
return sendPullSave(room.getCurrentStep());
}
+ public static Packet sendPullSave(ServerRoom room) throws IOException {
+ return sendPullSave(room.getCurrentStep());
+ }
+
public static Packet syncCheckSum(int step) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeInt(step);
@@ -271,6 +280,10 @@ public static Packet syncCheckSum(NetworkRoom room) throws IOException {
return syncCheckSum(room.getCurrentStep());
}
+ public static Packet syncCheckSum(ServerRoom room) throws IOException {
+ return syncCheckSum(room.getCurrentStep());
+ }
+
public static Packet gamePing(int step, int index, PingType type, float x, float y) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeInt(step);
@@ -306,6 +319,11 @@ public static Packet gamePing(NetworkRoom room, int index, PingType type, float
return gamePing(room.getCurrentStep(), index, type, x, y);
}
+ public static Packet gamePing(ServerRoom room, int index, PingType type, float x, float y)
+ throws IOException {
+ return gamePing(room.getCurrentStep(), index, type, x, y);
+ }
+
public static Packet gameSummon(int step, String unit, float x, float y, int team) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeInt(step);
@@ -367,11 +385,20 @@ public static Packet gameSummon(NetworkRoom room, String unit, float x, float y)
return gameSummon(room.getCurrentStep(), unit, x, y, -1);
}
+ public static Packet gameSummon(ServerRoom room, String unit, float x, float y) throws IOException {
+ return gameSummon(room.getCurrentStep(), unit, x, y, -1);
+ }
+
public static Packet gameSummon(NetworkRoom room, String unit, float x, float y, int team)
throws IOException {
return gameSummon(room.getCurrentStep(), unit, x, y, team);
}
+ public static Packet gameSummon(ServerRoom room, String unit, float x, float y, int team)
+ throws IOException {
+ return gameSummon(room.getCurrentStep(), unit, x, y, team);
+ }
+
public static Packet gameSurrounder(int step, int index) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeInt(step);
@@ -407,6 +434,10 @@ public static Packet gameSurrounder(NetworkRoom room, int index) throws IOExcept
return gameSurrounder(room.getCurrentStep(), index);
}
+ public static Packet gameSurrounder(ServerRoom room, int index) throws IOException {
+ return gameSurrounder(room.getCurrentStep(), index);
+ }
+
public static Packet packetQuestion(int questionId, String question) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeByte(1);
diff --git a/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java b/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
new file mode 100644
index 0000000..5b0b84d
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.PlayerManager;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.ChannelGroupFuture;
+import io.netty.channel.group.ChannelMatcher;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Master-compatible connection manager for {@link ServerRoom}. */
+public class RoomConnectionManager {
+ private final ServerRoom room;
+ public volatile List connections = new ArrayList<>();
+ private final ChannelGroup channelGroup;
+ private final PlayerManager playerManager;
+ private final Logger log;
+
+ public RoomConnectionManager(ServerRoom room) {
+ this.room = room;
+ this.playerManager = room.playerManager;
+ this.log = LoggerFactory.getLogger("RoomConnectionManager #" + room.roomId);
+ this.channelGroup = new DefaultChannelGroup(
+ "ChannelGroups" + room.roomId, GlobalEventExecutor.INSTANCE);
+ }
+
+ public void add(ServerRoomConnection connection) {
+ connections.add(connection);
+ playerManager.addWithTeam(connection.player);
+ channelGroup.add(connection.handler.ctx.channel());
+ }
+
+ public void set(ServerRoomConnection connection, int index) {
+ connections.add(connection);
+ playerManager.set(index, connection.player);
+ channelGroup.add(connection.handler.ctx.channel());
+ }
+
+ public ChannelGroupFuture broadcast(Packet packet) {
+ return channelGroup.writeAndFlush(packet);
+ }
+
+ public ChannelGroupFuture broadcast(Packet packet, ChannelMatcher matcher) {
+ return channelGroup.writeAndFlush(packet, matcher);
+ }
+
+ public ChannelGroup flush() {
+ return channelGroup.flush();
+ }
+
+ public boolean discard(ServerRoomConnection connection) {
+ connection.handler.ctx.disconnect();
+ connections.remove(connection);
+ playerManager.remove(connection.player);
+ if (connection.player.isAdmin && playerManager.getPlayerCount() > 0) {
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ if (!player.isEmpty && player.getServerConnection() != null) {
+ player.isAdmin = true;
+ try {
+ player.getServerConnection().sendPacket(
+ UniversalPacket.serverInfo(room.config, true));
+ } catch (IOException ignored) {
+ }
+ break;
+ }
+ }
+ }
+ return channelGroup.remove(connection.handler.ctx.channel());
+ }
+
+ public ChannelGroupFuture disconnect() {
+ return channelGroup.disconnect();
+ }
+
+ public ChannelGroupFuture disconnect(ChannelMatcher matcher) {
+ return channelGroup.disconnect(matcher);
+ }
+
+ public boolean contains(ServerRoomConnection connection) {
+ return channelGroup.contains(connection.handler.ctx.channel());
+ }
+
+ public int size() {
+ return channelGroup.size();
+ }
+
+ public List getConnections() {
+ return connections;
+ }
+
+ public SaveData getAvailableSave() {
+ for (ServerRoomConnection connection : connections) {
+ if (connection.save != null) {
+ log.debug("Get client save, tick={}, server tick={}",
+ connection.save.time, room.getCurrentStep());
+ if (Math.abs(connection.save.time - room.getCurrentStep()) < Integer.MAX_VALUE) {
+ return connection.save;
+ }
+ }
+ }
+ return null;
+ }
+
+ public void clearAllSaveData() {
+ for (ServerRoomConnection connection : connections) {
+ connection.save = null;
+ }
+ }
+
+ public void broadcastServerMessage(String message) {
+ try {
+ broadcast(UniversalPacket.chat("SERVER", message, -1));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void broadcastGlobalServerMessage(String message) {
+ broadcastServerMessage(message);
+ }
+
+ public void broadcastServerInfo() {
+ try {
+ broadcast(UniversalPacket.serverInfo(room.config, false));
+ } catch (IOException ignored) {
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoom.java b/src/main/java/cn/rukkit/network/room/ServerRoom.java
new file mode 100644
index 0000000..10cc7f4
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/room/ServerRoom.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.event.room.RoomStartGameEvent;
+import cn.rukkit.event.room.RoomStopGameEvent;
+import cn.rukkit.game.CheckSumList;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.PlayerManager;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.game.SaveManager;
+import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.util.Vote;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.Random;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * The master branch's NetworkRoom state machine moved to the new packet layer.
+ *
+ * This class deliberately keeps the master behavior and GameCommand model;
+ * unrelated network and simulation features are outside this migration.
+ */
+public class ServerRoom {
+ private static final Logger log = LoggerFactory.getLogger(ServerRoom.class);
+
+ public PlayerManager playerManager;
+ public RoomConnectionManager connectionManager;
+ private LinkedList commandQuere = new LinkedList();
+
+ public RoundConfig config;
+ public int stepRate = 200;
+ public int currentStep = 0;
+ public int checkSumFrame = 0;
+ public final AtomicInteger checkSumReceived = new AtomicInteger();
+ public int syncCount = 0;
+ public int roomId;
+
+ private volatile boolean checkRequested = false;
+ public SaveData lastNoStopSave;
+ private boolean isGaming = false;
+ private boolean isPaused = false;
+ private ScheduledFuture> gameTaskFuture;
+ private SaveManager saveManager;
+
+ public Vote vote;
+
+ @Override
+ public String toString() {
+ return MessageFormat.format(
+ "NetworkRoom [id = {0}, isGaming = {1}, isPaused = {2}, currentStep = {3}, stepRate = {4}]",
+ roomId, isGaming, isPaused, currentStep, stepRate);
+ }
+
+ public ServerRoom(int id) {
+ roomId = id;
+ playerManager = new PlayerManager(this, Rukkit.getConfig().maxPlayer);
+ connectionManager = new RoomConnectionManager(this);
+ saveManager = new SaveManager(this);
+ config = Rukkit.getRoundConfig();
+ vote = new Vote(this);
+ }
+
+ public class CheckSumTask implements Runnable {
+ Logger taskLog = LoggerFactory.getLogger("CheckSum Task Room #" + roomId);
+
+ public void check(int recheck) {
+ if (recheck >= 3) {
+ taskLog.error("Checksum failed!May be a resync is needed!");
+ syncGame();
+ return;
+ }
+
+ CheckSumList list = null;
+ int diffcount = 0;
+ AtomicInteger time = new AtomicInteger();
+ HashMap map = new HashMap();
+ for (ServerRoomConnection connection : connectionManager.getConnections()) {
+ if (connection.checkSumSent) {
+ map.put(connection.lastSyncTick,
+ map.getOrDefault(connection.lastSyncTick, 0) + 1);
+ }
+ }
+ AtomicInteger max = new AtomicInteger();
+ map.forEach((tick, count) -> {
+ if (count > max.get()) {
+ max.set(count);
+ time.set(tick);
+ }
+ });
+
+ for (ServerRoomConnection connection : connectionManager.getConnections()) {
+ if (!connection.checkSumSent) {
+ continue;
+ }
+ if (list == null) {
+ int random = new Random().nextInt(connectionManager.size());
+ if (connectionManager.getConnections().get(random).checkSumSent) {
+ list = connectionManager.getConnections().get(random).player.checkList;
+ } else {
+ continue;
+ }
+ }
+ if (time.get() != connection.lastSyncTick) {
+ continue;
+ }
+ if (!list.checkData(connection.player.checkList)) {
+ diffcount++;
+ }
+ }
+
+ if (diffcount >= Math.ceil(connectionManager.size() / 2.0)
+ && connectionManager.size() >= 2) {
+ taskLog.warn("diffcount {} > {} players!Do recheck!", diffcount,
+ Math.ceil(connectionManager.size() / 2.0));
+ check(recheck + 1);
+ } else if (diffcount >= 2) {
+ taskLog.info("Desync found.Resyncing game...");
+ syncGame();
+ } else {
+ taskLog.info("Checksum complete!");
+ }
+
+ for (ServerRoomConnection connection : connectionManager.getConnections()) {
+ connection.checkSumSent = false;
+ }
+ taskLog.info("diffcount: {}, maxSyncTime: {}", diffcount, time);
+ }
+
+ @Override
+ public void run() {
+ if (checkRequested) {
+ synchronized (checkSumReceived) {
+ while (true) {
+ try {
+ checkSumReceived.wait();
+ if (checkSumReceived.get() >= connectionManager.size()) {
+ break;
+ }
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ check(0);
+ checkRequested = false;
+ }
+ }
+ }
+
+ public class GameTask implements Runnable {
+ @Override
+ public void run() {
+ RukkitConfig cfg = Rukkit.getConfig();
+ if (!isPaused) {
+ currentStep += 10;
+ if (cfg.checksumSync && currentStep % 300 == 0) {
+ if (!checkRequested) {
+ checkSumReceived.set(0);
+ doChecksum();
+ } else {
+ checkSumReceived.set(connectionManager.size());
+ synchronized (checkSumReceived) {
+ checkSumReceived.notifyAll();
+ }
+ }
+ }
+ }
+ if (connectionManager.size() <= 0) {
+ stopGame();
+ Rukkit.getThreadManager().shutdownTask(gameTaskFuture);
+ return;
+ }
+ if (connectionManager.size() <= 1 && !cfg.singlePlayerMode) {
+ connectionManager.broadcastServerMessage("1 player left.Auto disconnecting...");
+ stopGame();
+ Rukkit.getThreadManager().shutdownTask(gameTaskFuture);
+ return;
+ }
+
+ synchronized (commandQuere) {
+ try {
+ if (commandQuere.isEmpty() && !isPaused) {
+ connectionManager.broadcast(UniversalPacket.emptyCommand(currentStep));
+ } else {
+ while (!commandQuere.isEmpty() && !isPaused) {
+ GameCommand command = commandQuere.removeLast();
+ connectionManager.broadcast(
+ UniversalPacket.gameCommand(currentStep, command));
+ }
+ }
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ }
+
+ public class NonStopGameTask implements Runnable {
+ @Override
+ public void run() {
+ RukkitConfig cfg = Rukkit.getConfig();
+ if (!isPaused) {
+ currentStep += 10;
+ }
+ if (connectionManager.size() == 1 && !cfg.singlePlayerMode && !isPaused) {
+ connectionManager.broadcastServerMessage(
+ "1 player left.We will have a sync and pause game...");
+ syncGame();
+ setPaused(true);
+ return;
+ }
+ if (connectionManager.size() <= 0) {
+ setPaused(true);
+ return;
+ }
+
+ synchronized (commandQuere) {
+ try {
+ if (commandQuere.isEmpty() && !isPaused) {
+ connectionManager.broadcast(UniversalPacket.emptyCommand(currentStep));
+ } else {
+ while (!commandQuere.isEmpty() && !isPaused) {
+ GameCommand command = commandQuere.removeLast();
+ connectionManager.broadcast(
+ UniversalPacket.gameCommand(currentStep, command));
+ }
+ }
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ }
+
+ public class SyncTask implements Runnable {
+ @Override
+ public void run() {
+ Logger syncLog = LoggerFactory.getLogger("SyncTask #" + roomId);
+ connectionManager.clearAllSaveData();
+ setPaused(true);
+ try {
+ connectionManager.broadcast(UniversalPacket.sendPullSave(ServerRoom.this));
+ SaveData save;
+ long time = System.currentTimeMillis();
+ while (true) {
+ save = connectionManager.getAvailableSave();
+ if (save != null) {
+ saveManager.setLastSave(save);
+ saveManager.sendLastSaveToAll(false);
+ syncCount++;
+ setPaused(false);
+ break;
+ } else if (System.currentTimeMillis() - time > 5000) {
+ syncLog.warn("Sync failed!");
+ setPaused(false);
+ break;
+ }
+ }
+ } catch (IOException e) {
+ syncLog.warn("A exception occurred.", e);
+ stopGame();
+ }
+ }
+ }
+
+ public boolean isPaused() {
+ return isPaused;
+ }
+
+ public void setPaused(boolean paused) {
+ isPaused = paused;
+ }
+
+ public void stopGame() {
+ stopGame(false);
+ }
+
+ public void doChecksum() {
+ checkRequested = true;
+ for (ServerRoomConnection connection : connectionManager.connections) {
+ connection.doChecksum();
+ }
+ Rukkit.getThreadManager().submit(new CheckSumTask());
+ }
+
+ public void stopGame(boolean returnToBattleroom) {
+ currentStep = 0;
+ checkSumFrame = 0;
+ syncCount = 0;
+ if (returnToBattleroom) {
+ try {
+ playerManager.clearDisconnectedPlayers();
+ connectionManager.broadcast(UniversalPacket.packetReturnToBattleroom());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ playerManager.reset();
+ connectionManager.disconnect();
+ }
+ if (gameTaskFuture != null) {
+ gameTaskFuture.cancel(true);
+ }
+ isGaming = false;
+ RoomStopGameEvent.getListenerList().callListeners(new RoomStopGameEvent(this));
+ }
+
+ public void broadcast(Packet packet) {
+ connectionManager.broadcast(packet);
+ }
+
+ public void discard() {
+ playerManager.reset();
+ connectionManager.disconnect();
+ connectionManager.clearAllSaveData();
+ playerManager = null;
+ connectionManager = null;
+ }
+
+ public boolean isGaming() {
+ if (currentStep <= 0) {
+ isGaming = false;
+ } else {
+ isGaming = true;
+ }
+ return isGaming;
+ }
+
+ public void syncGame() {
+ Rukkit.getThreadManager().submit(new SyncTask());
+ }
+
+ public void startGame() {
+ try {
+ connectionManager.broadcast(UniversalPacket.gameStart());
+ if (config.sharedControl) {
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ try {
+ player.isNull();
+ player.isSharingControl = false;
+ } catch (NullPointerException ignored) {
+ continue;
+ }
+ }
+ }
+ currentStep = 0;
+ connectionManager.broadcast(UniversalPacket.serverInfo(config));
+ for (ServerRoomConnection connection : connectionManager.getConnections()) {
+ connection.updateTeamList();
+ }
+ gameTaskFuture = Rukkit.getThreadManager().schedule(new GameTask(), stepRate, stepRate);
+ isGaming = true;
+ RoomStartGameEvent.getListenerList().callListeners(new RoomStartGameEvent(this));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void changeMapWhileRunning(String mapName, int type) {
+ Rukkit.getRoundConfig().mapName = mapName;
+ Rukkit.getRoundConfig().mapType = type;
+ try {
+ connectionManager.broadcast(UniversalPacket.gameStart());
+ if (Rukkit.getRoundConfig().sharedControl) {
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ try {
+ player.isNull();
+ } catch (NullPointerException ignored) {
+ continue;
+ }
+ }
+ }
+ currentStep = 0;
+ connectionManager.broadcast(UniversalPacket.serverInfo(config));
+ for (ServerRoomConnection connection : connectionManager.getConnections()) {
+ connection.updateTeamList(false);
+ }
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void notifyGameTask() {
+ setPaused(false);
+ }
+
+ public int getTickTime() {
+ return currentStep;
+ }
+
+ public int getCurrentStep() {
+ return currentStep;
+ }
+
+ public void addCommand(GameCommand command) {
+ if (Rukkit.getConfig().useCommandQuere) {
+ commandQuere.addLast(command);
+ } else {
+ try {
+ broadcast(UniversalPacket.gameCommand(currentStep, command));
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ public void summonUnit(String unitName, float x, float y, int player) {
+ try {
+ broadcast(UniversalPacket.gameSummon(currentStep, unitName, x, y, player));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void summonUnit(String unitName, float x, float y) {
+ try {
+ broadcast(UniversalPacket.gameSummon(currentStep, unitName, x, y));
+ } catch (IOException ignored) {
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
new file mode 100644
index 0000000..f26a0a4
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.network.ConnectionHandler;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.io.GzipEncoder;
+import cn.rukkit.util.GameUtils;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledFuture;
+
+/**
+ * Master-compatible counterpart of the legacy RoomConnection.
+ *
+ * The command type intentionally remains {@link GameCommand}; command
+ * parsing is outside this migration step.
+ */
+public class ServerRoomConnection {
+ public NetworkPlayer player;
+ public ConnectionHandler handler;
+ public ServerRoom currectRoom;
+ public long pingTime;
+ public int lastSyncTick = 0;
+ public boolean checkSumSent = false;
+ public int numberOfDesyncError = 0;
+ public SaveData save;
+
+ private ScheduledFuture> pingFuture;
+ private ScheduledFuture> teamFuture;
+
+ public ServerRoomConnection(ConnectionHandler handler, ServerRoom currectRoom) {
+ this.handler = handler;
+ this.currectRoom = currectRoom;
+ }
+
+ public void startPingTask() {
+ if (pingFuture != null) {
+ return;
+ }
+ pingFuture = Rukkit.getThreadManager().schedule(new PingTasker(), 2000, 2000);
+ }
+
+ public void startTeamTask() {
+ if (teamFuture != null) {
+ return;
+ }
+ teamFuture = Rukkit.getThreadManager().schedule(new TeamTasker(), 1000, 1000);
+ }
+
+ public void stopPingTask() {
+ if (pingFuture == null) {
+ return;
+ }
+ Rukkit.getThreadManager().shutdownTask(pingFuture);
+ pingFuture = null;
+ }
+
+ public void stopTeamTask() {
+ if (teamFuture == null) {
+ return;
+ }
+ Rukkit.getThreadManager().shutdownTask(teamFuture);
+ teamFuture = null;
+ }
+
+ public class PingTasker implements Runnable {
+ @Override
+ public void run() {
+ try {
+ sendPacket(UniversalPacket.ping());
+ pingTime = System.currentTimeMillis();
+ } catch (IOException e) {
+ stopPingTask();
+ }
+ }
+ }
+
+ public class TeamTasker implements Runnable {
+ @Override
+ public void run() {
+ try {
+ updateTeamList();
+ } catch (IOException e) {
+ stopTeamTask();
+ }
+ }
+ }
+
+ public void doChecksum() {
+ try {
+ sendPacket(UniversalPacket.syncCheckSum(lastSyncTick));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void sendChat(String msg) {
+ try {
+ currectRoom.connectionManager.broadcast(
+ UniversalPacket.chat(player.name, msg, player.playerIndex));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void sendServerMessage(String msg) {
+ try {
+ sendPacket(UniversalPacket.chat("SERVER", msg, -1));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void sendMessage(String from, String msg, int team) {
+ try {
+ sendPacket(UniversalPacket.chat(from, msg, team));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void sendGameCommand(GameCommand cmd) {
+ if (currectRoom.isPaused()) {
+ return;
+ }
+ if (Rukkit.getConfig().useCommandQuere) {
+ currectRoom.addCommand(cmd);
+ } else {
+ try {
+ currectRoom.connectionManager.broadcast(
+ UniversalPacket.gameCommand(currectRoom.getTickTime(), cmd));
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ public void updateTeamList() throws IOException {
+ updateTeamList(currectRoom.isGaming());
+ }
+
+ public void updateTeamList(boolean simpleMode) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeInt(player.playerIndex);
+ output.writeBoolean(simpleMode);
+ output.writeInt(Rukkit.getConfig().maxPlayer);
+ GzipEncoder encoder = output.getEncodeStream("teams", true);
+
+ for (int i = 0; i < Rukkit.getConfig().maxPlayer; i++) {
+ NetworkPlayer currentPlayer = currectRoom.playerManager.get(i);
+ encoder.stream.writeBoolean(!currentPlayer.isEmpty);
+ if (currentPlayer.isEmpty) {
+ continue;
+ }
+ encoder.stream.writeInt(255);
+ currentPlayer.writePlayer(encoder.stream, simpleMode);
+ }
+ output.flushEncodeData(encoder);
+ output.writeInt(currectRoom.config.fogType);
+ output.writeInt(GameUtils.getMoneyFormat(currectRoom.config.credits));
+ output.writeBoolean(true);
+ output.writeInt(1);
+ output.writeByte(4);
+ output.writeInt(250);
+ output.writeInt(250);
+ output.writeInt(currectRoom.config.startingUnits);
+ output.writeFloat(currectRoom.config.income);
+ output.writeBoolean(currectRoom.config.disableNuke);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(currectRoom.config.sharedControl);
+ sendPacket(output.createPacket(PacketType.TEAM_LIST));
+ }
+
+ public void kick(String reason) {
+ try {
+ sendPacket(UniversalPacket.kick(reason));
+ } catch (IOException ignored) {
+ }
+ }
+
+ public void sendPacket(Packet packet) {
+ handler.ctx.writeAndFlush(packet);
+ }
+
+ public void pong() {
+ player.ping = (int) (System.currentTimeMillis() - pingTime);
+ }
+}
diff --git a/src/main/java/cn/rukkit/util/Vote.java b/src/main/java/cn/rukkit/util/Vote.java
index c4c380a..65a429f 100644
--- a/src/main/java/cn/rukkit/util/Vote.java
+++ b/src/main/java/cn/rukkit/util/Vote.java
@@ -12,6 +12,7 @@
import cn.rukkit.Rukkit;
import cn.rukkit.network.NetworkRoom;
import cn.rukkit.network.RoomConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
import java.text.MessageFormat;
import java.util.Arrays;
@@ -22,6 +23,7 @@ public class Vote{
public String voteId = "null";
// 房间实例
NetworkRoom room;
+ ServerRoom serverRoom;
private int agree = 0;
private int disagree = 0;
private int timeRemain = 15;
@@ -37,8 +39,11 @@ public boolean submitVoting(final Runnable runnable, String id, String reason, i
if (isVoting) {
return false;
}
- RoomConnectionManager con = room.connectionManager;
- con.broadcastServerMessage(reason);
+ if (room != null) {
+ room.connectionManager.broadcastServerMessage(reason);
+ } else {
+ serverRoom.connectionManager.broadcastServerMessage(reason);
+ }
timeRemain = timeRem;
voteId = id;
voteDesc = reason;
@@ -47,22 +52,22 @@ public boolean submitVoting(final Runnable runnable, String id, String reason, i
@Override
public void run() {
// No player exists.Stop vote.
- if (room.connectionManager.size() <= 0) {
+ if (connectionCount() <= 0) {
stopVote();
}
if (timeRemain == 0) {
if (agree >= disagree) {
- con.broadcastServerMessage(
+ broadcastServerMessage(
MessageFormat.format(LangUtil.getString("nostop.vote.success"), agree, disagree));
runnable.run();
} else {
- con.broadcastServerMessage(
+ broadcastServerMessage(
MessageFormat.format(LangUtil.getString("nostop.vote.failure"), agree, disagree));
}
stopVote();
}
if (timeRemain % 10 == 0) {
- con.broadcastServerMessage(MessageFormat.format(LangUtil.getString("nostop.vote.timeRemain"), timeRemain));
+ broadcastServerMessage(MessageFormat.format(LangUtil.getString("nostop.vote.timeRemain"), timeRemain));
}
timeRemain --;
}
@@ -76,6 +81,11 @@ public Vote(NetworkRoom room) {
voteState = new boolean[room.playerManager.getMaxPlayer()];
}
+ public Vote(ServerRoom room) {
+ this.serverRoom = room;
+ voteState = new boolean[room.playerManager.getMaxPlayer()];
+ }
+
public boolean agree(int index) {
if (isVoting && !voteState[index]) {
agree++;
@@ -101,4 +111,16 @@ public void stopVote() {
Arrays.fill(voteState, false);
if (voteFuture != null) Rukkit.getThreadManager().shutdownTask(voteFuture);
}
-}
\ No newline at end of file
+
+ private int connectionCount() {
+ return room != null ? room.connectionManager.size() : serverRoom.connectionManager.size();
+ }
+
+ private void broadcastServerMessage(String message) {
+ if (room != null) {
+ room.connectionManager.broadcastServerMessage(message);
+ } else {
+ serverRoom.connectionManager.broadcastServerMessage(message);
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
new file mode 100644
index 0000000..592b08c
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.command.GameCommand;
+import java.lang.reflect.Field;
+import java.util.LinkedList;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerRoomBehaviorTest {
+ private Object previousConfig;
+ private Object previousRound;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ previousConfig = setStatic("config", new RukkitConfig());
+ previousRound = setStatic("round", new RoundConfig());
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ }
+
+ @Test
+ void initialStateMatchesLegacyRoom() {
+ NetworkRoom legacy = new NetworkRoom(2);
+ ServerRoom migrated = new ServerRoom(2);
+
+ assertEquals(legacy.roomId, migrated.roomId);
+ assertEquals(legacy.currentStep, migrated.currentStep);
+ assertEquals(legacy.stepRate, migrated.stepRate);
+ assertEquals(legacy.playerManager.getMaxPlayer(), migrated.playerManager.getMaxPlayer());
+ assertEquals(legacy.connectionManager.size(), migrated.connectionManager.size());
+ assertEquals(legacy.toString(), migrated.toString());
+ assertFalse(legacy.isPaused());
+ assertFalse(migrated.isPaused());
+ assertFalse(legacy.isGaming());
+ assertFalse(migrated.isGaming());
+ }
+
+ @Test
+ void pauseAndRunningStateMatchLegacyRoom() {
+ NetworkRoom legacy = new NetworkRoom(2);
+ ServerRoom migrated = new ServerRoom(2);
+
+ legacy.setPaused(true);
+ migrated.setPaused(true);
+ assertTrue(legacy.isPaused());
+ assertTrue(migrated.isPaused());
+
+ legacy.currentStep = 10;
+ migrated.currentStep = 10;
+ assertEquals(legacy.isGaming(), migrated.isGaming());
+ assertTrue(migrated.isGaming());
+
+ legacy.setPaused(false);
+ migrated.setPaused(false);
+ assertEquals(legacy.isPaused(), migrated.isPaused());
+ }
+
+ @Test
+ void commandQueueModeMatchesLegacyRoom() throws ReflectiveOperationException {
+ RukkitConfig config = (RukkitConfig) getStatic("config");
+ config.useCommandQuere = true;
+ NetworkRoom legacy = new NetworkRoom(2);
+ ServerRoom migrated = new ServerRoom(2);
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {1, 2, 3};
+
+ legacy.addCommand(command);
+ migrated.addCommand(command);
+
+ assertEquals(queueSize(NetworkRoom.class, legacy), queueSize(ServerRoom.class, migrated));
+ assertEquals(1, queueSize(ServerRoom.class, migrated));
+ }
+
+ private static int queueSize(Class> roomType, Object room) throws ReflectiveOperationException {
+ Field queue = roomType.getDeclaredField("commandQuere");
+ queue.setAccessible(true);
+ return ((LinkedList>) queue.get(room)).size();
+ }
+
+ private static Object getStatic(String name) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(null);
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+}
From 2bc0d89b3dff351736e6d394225069766004f576 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Fri, 7 Aug 2026 15:34:32 +0800
Subject: [PATCH 3/9] feat(network): continue master room migration
---
.../cn/rukkit/network/room/ServerRoom.java | 4 +-
.../network/room/ServerRoomManager.java | 91 ++++++++
.../ServerRoomConnectionBehaviorTest.java | 201 ++++++++++++++++++
.../room/ServerRoomManagerBehaviorTest.java | 94 ++++++++
4 files changed, 389 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/cn/rukkit/network/room/ServerRoomManager.java
create mode 100644 src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
create mode 100644 src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoom.java b/src/main/java/cn/rukkit/network/room/ServerRoom.java
index 10cc7f4..89e62cc 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoom.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoom.java
@@ -19,6 +19,7 @@
import cn.rukkit.game.PlayerManager;
import cn.rukkit.game.SaveData;
import cn.rukkit.game.SaveManager;
+import cn.rukkit.network.ConnectionState;
import cn.rukkit.network.command.GameCommand;
import cn.rukkit.network.core.packet.Packet;
import cn.rukkit.network.core.packet.UniversalPacket;
@@ -351,7 +352,7 @@ public void syncGame() {
public void startGame() {
try {
connectionManager.broadcast(UniversalPacket.gameStart());
- if (config.sharedControl) {
+ if (Rukkit.getRoundConfig().sharedControl) {
for (NetworkPlayer player : playerManager.getPlayerArray()) {
try {
player.isNull();
@@ -365,6 +366,7 @@ public void startGame() {
connectionManager.broadcast(UniversalPacket.serverInfo(config));
for (ServerRoomConnection connection : connectionManager.getConnections()) {
connection.updateTeamList();
+ connection.handler.setState(ConnectionState.IN_GAME);
}
gameTaskFuture = Rukkit.getThreadManager().schedule(new GameTask(), stepRate, stepRate);
isGaming = true;
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomManager.java b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
new file mode 100644
index 0000000..905a34b
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Master-compatible room registry for the migrated room model.
+ *
+ * The registry is intentionally not installed into {@link Rukkit} yet.
+ * The existing runtime still uses the legacy {@code RoomManager} until the
+ * network entry point is migrated.
+ */
+public class ServerRoomManager {
+ public List roomList;
+
+ public ServerRoomManager(RoundConfig defaultConfig, int maxRoom) {
+ roomList = new ArrayList<>(maxRoom);
+ resetAllRooms();
+ }
+
+ /**
+ * Kept as a compatibility placeholder because the master implementation
+ * does not register connections through the room manager yet.
+ */
+ public void addConnection(ServerRoomConnection connection, int roomId) {
+ }
+
+ /**
+ * Kept as a compatibility placeholder because the master implementation
+ * does not register connections through the room manager yet.
+ */
+ public void addConnection(ServerRoomConnection connection) {
+ }
+
+ public ServerRoom getDefaultRoom() {
+ return roomList.get(0);
+ }
+
+ public ServerRoom getRoom(int index) {
+ return roomList.get(index);
+ }
+
+ public ServerRoom getAvailableRoom() {
+ for (ServerRoom room : roomList) {
+ if (room.playerManager.getPlayerCount() < room.playerManager.getMaxPlayer()
+ && !room.isGaming()) {
+ return room;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Reset all rooms while preserving the master lifecycle intent.
+ *
+ * The legacy implementation removes entries from {@code roomList}
+ * while iterating the same list, which fails as soon as the list contains
+ * a room. The migrated registry performs the same broadcast/disconnect/
+ * discard sequence on a snapshot and then rebuilds the list.
+ */
+ public void resetAllRooms() {
+ for (ServerRoom room : new ArrayList<>(roomList)) {
+ if (room == null) {
+ continue;
+ }
+ if (room.connectionManager != null) {
+ room.connectionManager.broadcastServerMessage("Room reset.");
+ room.connectionManager.disconnect();
+ }
+ if (room.playerManager != null && room.connectionManager != null) {
+ room.discard();
+ }
+ }
+ roomList.clear();
+ for (int id = 0; id < Rukkit.getConfig().maxRoom; id++) {
+ roomList.add(new ServerRoom(id));
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
new file mode 100644
index 0000000..6e8474c
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionHandler;
+import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.RoomConnection;
+import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that the migrated connection keeps the master connection behavior
+ * while its packets have moved to the core packet type.
+ */
+class ServerRoomConnectionBehaviorTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxPlayer = 2;
+ config.useCommandQuere = false;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(1);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ for (ConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void connectionActionsKeepLegacyPacketWireFormat() throws Exception {
+ LegacyFixture legacy = new LegacyFixture();
+ MigratedFixture migrated = new MigratedFixture();
+ configurePlayers(legacy.connection.player, migrated.connection.player);
+
+ legacy.connection.sendChat("hello");
+ migrated.connection.sendChat("hello");
+ assertEquivalent(legacy.channel, migrated.channel);
+
+ legacy.connection.sendServerMessage("server message");
+ migrated.connection.sendServerMessage("server message");
+ assertEquivalent(legacy.channel, migrated.channel);
+
+ legacy.connection.sendMessage("Alice", "team message", 1);
+ migrated.connection.sendMessage("Alice", "team message", 1);
+ assertEquivalent(legacy.channel, migrated.channel);
+
+ legacy.connection.kick("bye");
+ migrated.connection.kick("bye");
+ assertEquivalent(legacy.channel, migrated.channel);
+
+ legacy.connection.lastSyncTick = 42;
+ migrated.connection.lastSyncTick = 42;
+ legacy.connection.doChecksum();
+ migrated.connection.doChecksum();
+ assertEquivalent(legacy.channel, migrated.channel);
+
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {1, 3, 5, 7};
+ legacy.connection.currectRoom.currentStep = 42;
+ migrated.connection.currectRoom.currentStep = 42;
+ legacy.connection.sendGameCommand(command);
+ migrated.connection.sendGameCommand(command);
+ assertEquivalent(legacy.channel, migrated.channel);
+ }
+
+ @Test
+ void teamListKeepsLegacyPacketWireFormatInSimpleMode() throws Exception {
+ LegacyFixture legacy = new LegacyFixture();
+ MigratedFixture migrated = new MigratedFixture();
+ configurePlayers(legacy.connection.player, migrated.connection.player);
+
+ legacy.connection.updateTeamList(true);
+ migrated.connection.updateTeamList(true);
+
+ assertEquivalent(legacy.channel, migrated.channel);
+ }
+
+ @Test
+ void pingTaskUsesLegacyHeartbeatShape() {
+ LegacyFixture legacy = new LegacyFixture();
+ MigratedFixture migrated = new MigratedFixture();
+
+ legacy.connection.new PingTasker().run();
+ migrated.connection.new PingTasker().run();
+
+ cn.rukkit.network.packet.Packet legacyPacket = legacy.channel.readOutbound();
+ cn.rukkit.network.core.packet.Packet migratedPacket = migrated.channel.readOutbound();
+ assertNotNull(legacyPacket);
+ assertNotNull(migratedPacket);
+ assertEquals(legacyPacket.type, migratedPacket.type);
+ assertEquals(9, legacyPacket.bytes.length);
+ assertEquals(9, migratedPacket.bytes.length);
+ assertTrue(legacy.connection.pingTime > 0);
+ assertTrue(migrated.connection.pingTime > 0);
+ }
+
+ private void configurePlayers(NetworkPlayer legacyPlayer, NetworkPlayer migratedPlayer) {
+ for (NetworkPlayer player : new NetworkPlayer[] {legacyPlayer, migratedPlayer}) {
+ player.name = "Alice";
+ player.credits = 1234;
+ player.team = 0;
+ player.ping = 37;
+ }
+ }
+
+ private static void assertEquivalent(EmbeddedChannel legacyChannel,
+ EmbeddedChannel migratedChannel) {
+ cn.rukkit.network.packet.Packet legacyPacket = legacyChannel.readOutbound();
+ cn.rukkit.network.core.packet.Packet migratedPacket = migratedChannel.readOutbound();
+ assertNotNull(legacyPacket);
+ assertNotNull(migratedPacket);
+ assertEquals(legacyPacket.type, migratedPacket.type);
+ assertArrayEquals(legacyPacket.bytes, migratedPacket.bytes);
+ }
+
+ private ConnectionHandler newHandler() {
+ ConnectionHandler handler = new ConnectionHandler();
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ channels.add(channel);
+ handlers.add(handler);
+ return handler;
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private final class LegacyFixture {
+ private final EmbeddedChannel channel;
+ private final RoomConnection connection;
+
+ private LegacyFixture() {
+ NetworkRoom room = new NetworkRoom(1);
+ ConnectionHandler handler = newHandler();
+ channel = channels.get(channels.size() - 1);
+ connection = new RoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ room.connectionManager.add(connection);
+ }
+ }
+
+ private final class MigratedFixture {
+ private final EmbeddedChannel channel;
+ private final ServerRoomConnection connection;
+
+ private MigratedFixture() {
+ ServerRoom room = new ServerRoom(1);
+ ConnectionHandler handler = newHandler();
+ channel = channels.get(channels.size() - 1);
+ connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ room.connectionManager.add(connection);
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
new file mode 100644
index 0000000..769c5bc
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import java.lang.reflect.Field;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerRoomManagerBehaviorTest {
+ private Object previousConfig;
+ private Object previousRound;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 3;
+ config.maxPlayer = 2;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ }
+
+ @Test
+ void createsAndExposesMasterCompatibleRoomList() {
+ ServerRoomManager manager = new ServerRoomManager(Rukkit.getRoundConfig(), 3);
+
+ assertEquals(3, manager.roomList.size());
+ assertSame(manager.roomList.get(0), manager.getDefaultRoom());
+ assertEquals(0, manager.getRoom(0).roomId);
+ assertEquals(1, manager.getRoom(1).roomId);
+ assertEquals(2, manager.getRoom(2).roomId);
+ }
+
+ @Test
+ void selectsFirstNonGamingRoomWithCapacity() {
+ ServerRoomManager manager = new ServerRoomManager(Rukkit.getRoundConfig(), 3);
+ ServerRoom first = manager.getRoom(0);
+ first.playerManager.add(activePlayer());
+ first.playerManager.add(activePlayer());
+
+ ServerRoom available = manager.getAvailableRoom();
+
+ assertNotNull(available);
+ assertEquals(1, available.roomId);
+ }
+
+ @Test
+ void resetRebuildsRoomsWithoutConcurrentModification() {
+ ServerRoomManager manager = new ServerRoomManager(Rukkit.getRoundConfig(), 3);
+ ServerRoom oldRoom = manager.getRoom(0);
+
+ manager.resetAllRooms();
+
+ assertEquals(3, manager.roomList.size());
+ assertTrue(oldRoom != manager.getRoom(0));
+ assertEquals(0, manager.getRoom(0).roomId);
+ }
+
+ private static NetworkPlayer activePlayer() {
+ NetworkPlayer player = new NetworkPlayer();
+ player.isEmpty = false;
+ return player;
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+}
From 443243e79924adfc0b11d1f0d33a556721857ca3 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Fri, 7 Aug 2026 16:02:37 +0800
Subject: [PATCH 4/9] feat(network): add core connection handler foundation
---
.../core/handler/ServerConnectionHandler.java | 169 +++++++++++++++++
.../core/handler/ServerPacketContext.java | 33 ++++
.../core/handler/ServerPacketHandler.java | 34 ++++
.../handler/ServerPacketHandlerManager.java | 50 +++++
.../network/room/ServerRoomConnection.java | 6 +-
.../handler/ServerPacketHandlerChainTest.java | 176 ++++++++++++++++++
.../ServerRoomConnectionBehaviorTest.java | 27 ++-
7 files changed, 486 insertions(+), 9 deletions(-)
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerPacketContext.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerPacketHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
create mode 100644 src/test/java/cn/rukkit/network/core/handler/ServerPacketHandlerChainTest.java
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
new file mode 100644
index 0000000..51b892c
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.event.player.PlayerLeftEvent;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.core.packet.Packet;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.util.ReferenceCountUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Objects;
+import java.util.concurrent.ScheduledFuture;
+import java.util.function.Consumer;
+
+/**
+ * Connection lifecycle handler for the core packet pipeline.
+ *
+ * The global connection cleanup callback is injected so this class does
+ * not depend on the legacy global manager while the two runtimes coexist.
+ */
+public class ServerConnectionHandler extends ChannelInboundHandlerAdapter {
+ private final Logger log = LoggerFactory.getLogger(ServerConnectionHandler.class);
+ private final ServerPacketHandlerManager packetHandlerManager;
+ private final Consumer globalDiscard;
+
+ public ChannelHandlerContext ctx;
+ private ServerRoomConnection conn;
+ private ConnectionState state = ConnectionState.CONNECTED;
+ private ScheduledFuture> timeoutFuture;
+ private ServerRoom currentRoom;
+ private String disconnectReason = "Unknown";
+
+ public ServerConnectionHandler(ServerPacketHandlerManager packetHandlerManager) {
+ this(packetHandlerManager, connection -> {
+ });
+ }
+
+ public ServerConnectionHandler(ServerPacketHandlerManager packetHandlerManager,
+ Consumer globalDiscard) {
+ this.packetHandlerManager = Objects.requireNonNull(packetHandlerManager,
+ "packetHandlerManager must not be null");
+ this.globalDiscard = Objects.requireNonNull(globalDiscard,
+ "globalDiscard must not be null");
+ }
+
+ public class TimeoutTask implements Runnable {
+ private int execTime;
+
+ @Override
+ public void run() {
+ execTime++;
+ if (execTime >= Rukkit.getConfig().registerTimeout && ctx != null) {
+ ctx.disconnect();
+ }
+ }
+ }
+
+ @Override
+ public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
+ super.channelRegistered(ctx);
+ this.ctx = ctx;
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ super.channelActive(ctx);
+ startTimeout();
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ super.channelInactive(ctx);
+ setState(ConnectionState.DISCONNECTED);
+ if (conn != null) {
+ PlayerLeftEvent.getListenerList().callListeners(
+ new PlayerLeftEvent(conn.player, disconnectReason));
+ if (currentRoom != null && currentRoom.connectionManager != null) {
+ currentRoom.connectionManager.discard(conn);
+ }
+ globalDiscard.accept(conn);
+ conn.stopPingTask();
+ conn.stopTeamTask();
+ } else {
+ log.debug("Unregistered connection closed from {}", ctx.channel().remoteAddress());
+ }
+ stopTimeout();
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ try {
+ if (!(msg instanceof Packet packet)) {
+ log.debug("Ignoring non-core packet {} from {}", msg.getClass().getName(),
+ ctx.channel().remoteAddress());
+ return;
+ }
+ if (!packetHandlerManager.dispatch(new ServerPacketContext(ctx, conn, this), packet)) {
+ log.debug("Unhandled packet type {} from {}", packet.type,
+ ctx.channel().remoteAddress());
+ }
+ } finally {
+ ReferenceCountUtil.release(msg);
+ }
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
+ log.warn("Exception happened", cause);
+ }
+
+ public ConnectionState getState() {
+ return state;
+ }
+
+ public void setState(ConnectionState state) {
+ this.state = Objects.requireNonNull(state, "state must not be null");
+ }
+
+ public ServerRoomConnection getConn() {
+ return conn;
+ }
+
+ public void setConn(ServerRoomConnection conn) {
+ this.conn = conn;
+ this.currentRoom = conn == null ? null : conn.currectRoom;
+ }
+
+ public ServerRoom getCurrentRoom() {
+ return currentRoom;
+ }
+
+ public void setCurrentRoom(ServerRoom currentRoom) {
+ this.currentRoom = currentRoom;
+ }
+
+ public String getDisconnectReason() {
+ return disconnectReason;
+ }
+
+ public void setDisconnectReason(String disconnectReason) {
+ this.disconnectReason = disconnectReason;
+ }
+
+ public void startTimeout() {
+ if (timeoutFuture == null) {
+ timeoutFuture = Rukkit.getThreadManager().schedule(new TimeoutTask(), 1000, 1000);
+ }
+ }
+
+ public void stopTimeout() {
+ if (timeoutFuture != null) {
+ Rukkit.getThreadManager().shutdownTask(timeoutFuture);
+ timeoutFuture = null;
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPacketContext.java b/src/main/java/cn/rukkit/network/core/handler/ServerPacketContext.java
new file mode 100644
index 0000000..6a647de
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPacketContext.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.room.ServerRoomConnection;
+import io.netty.channel.ChannelHandlerContext;
+
+/** Packet context passed from the core packet pipeline to a server handler. */
+public record ServerPacketContext(
+ ChannelHandlerContext ctx,
+ ServerRoomConnection connection,
+ ServerConnectionHandler handler
+) {
+ public ConnectionState state() {
+ return handler.getState();
+ }
+
+ public void transitionTo(ConnectionState state) {
+ handler.setState(state);
+ }
+
+ public void bindConnection(ServerRoomConnection connection) {
+ handler.setConn(connection);
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandler.java
new file mode 100644
index 0000000..63292ba
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandler.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/** Base contract for handlers that consume core-layer packets. */
+public abstract class ServerPacketHandler {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ public abstract int getType();
+
+ public List getAllowedStates() {
+ return List.of(ConnectionState.values());
+ }
+
+ public abstract void handle(ServerPacketContext context, Packet packet) throws Exception;
+
+ protected Logger getLogger() {
+ return logger;
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
new file mode 100644
index 0000000..5142d4c
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Dispatches core-layer packets while enforcing connection state guards. */
+public class ServerPacketHandlerManager {
+ private static final Logger log = LoggerFactory.getLogger(ServerPacketHandlerManager.class);
+ private final Map handlers = new HashMap<>();
+
+ public void register(ServerPacketHandler handler) {
+ handlers.put(handler.getType(), handler);
+ }
+
+ public void unregister(int type) {
+ handlers.remove(type);
+ }
+
+ public void unregister(ServerPacketHandler handler) {
+ unregister(handler.getType());
+ }
+
+ public boolean dispatch(ServerPacketContext context,
+ cn.rukkit.network.core.packet.Packet packet) throws Exception {
+ ServerPacketHandler handler = handlers.get(packet.type);
+ if (handler == null) {
+ return false;
+ }
+ if (!handler.getAllowedStates().contains(context.state())) {
+ log.warn("Packet {} blocked by state {} from {}", packet.type, context.state(),
+ context.ctx().channel().remoteAddress());
+ context.ctx().close();
+ return true;
+ }
+ handler.handle(context, packet);
+ return true;
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
index f26a0a4..abe9fd8 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
@@ -13,7 +13,7 @@
import cn.rukkit.game.NetworkPlayer;
import cn.rukkit.game.SaveData;
import cn.rukkit.network.command.GameCommand;
-import cn.rukkit.network.ConnectionHandler;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
import cn.rukkit.network.core.packet.Packet;
import cn.rukkit.network.core.packet.PacketType;
import cn.rukkit.network.core.packet.UniversalPacket;
@@ -32,7 +32,7 @@
*/
public class ServerRoomConnection {
public NetworkPlayer player;
- public ConnectionHandler handler;
+ public ServerConnectionHandler handler;
public ServerRoom currectRoom;
public long pingTime;
public int lastSyncTick = 0;
@@ -43,7 +43,7 @@ public class ServerRoomConnection {
private ScheduledFuture> pingFuture;
private ScheduledFuture> teamFuture;
- public ServerRoomConnection(ConnectionHandler handler, ServerRoom currectRoom) {
+ public ServerRoomConnection(ServerConnectionHandler handler, ServerRoom currectRoom) {
this.handler = handler;
this.currectRoom = currectRoom;
}
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerPacketHandlerChainTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerPacketHandlerChainTest.java
new file mode 100644
index 0000000..f74ec41
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerPacketHandlerChainTest.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+class ServerPacketHandlerChainTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxPlayer = 2;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(1);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void dispatchesCorePacketAndAllowsStateTransition() {
+ ServerPacketHandlerManager manager = new ServerPacketHandlerManager();
+ AtomicReference received = new AtomicReference<>();
+ manager.register(new ServerPacketHandler() {
+ @Override
+ public int getType() {
+ return 7;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.CONNECTED);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) {
+ received.set(context);
+ context.transitionTo(ConnectionState.PRE_REGISTERED);
+ }
+ });
+
+ ServerConnectionHandler handler = newHandler(manager);
+ EmbeddedChannel channel = channels.get(channels.size() - 1);
+
+ assertFalse(channel.writeInbound(new Packet(7, new byte[] {1, 2, 3})));
+ assertSame(handler, received.get().handler());
+ assertEquals(ConnectionState.PRE_REGISTERED, handler.getState());
+ }
+
+ @Test
+ void closesConnectionWhenPacketStateIsNotAllowed() {
+ ServerPacketHandlerManager manager = new ServerPacketHandlerManager();
+ manager.register(new ServerPacketHandler() {
+ @Override
+ public int getType() {
+ return 8;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.PRE_REGISTERED);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) {
+ throw new AssertionError("blocked packet must not reach handler");
+ }
+ });
+
+ ServerConnectionHandler handler = newHandler(manager);
+ EmbeddedChannel channel = channels.get(channels.size() - 1);
+
+ channel.writeInbound(new Packet(8, new byte[] {9}));
+
+ assertFalse(channel.isOpen());
+ assertEquals(ConnectionState.DISCONNECTED, handler.getState());
+ }
+
+ @Test
+ void contextBindingMovesRoomConnectionIntoHandler() {
+ ServerPacketHandlerManager manager = new ServerPacketHandlerManager();
+ ServerConnectionHandler handler = newHandler(manager);
+ ServerRoom room = new ServerRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+
+ ServerPacketContext context = new ServerPacketContext(handler.ctx, null, handler);
+ context.bindConnection(connection);
+
+ assertSame(connection, handler.getConn());
+ assertSame(room, handler.getCurrentRoom());
+ }
+
+ @Test
+ void disconnectInvokesGlobalCleanupCallback() {
+ ServerPacketHandlerManager manager = new ServerPacketHandlerManager();
+ AtomicReference discarded = new AtomicReference<>();
+ ServerConnectionHandler handler = new ServerConnectionHandler(manager, discarded::set);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = new ServerRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ room.connectionManager.add(connection);
+ handler.setConn(connection);
+
+ channel.close();
+
+ assertSame(connection, discarded.get());
+ assertFalse(room.connectionManager.contains(connection));
+ }
+
+ private ServerConnectionHandler newHandler(ServerPacketHandlerManager manager) {
+ ServerConnectionHandler handler = new ServerConnectionHandler(manager);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+ return handler;
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
index 6e8474c..0f8de5f 100644
--- a/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomConnectionBehaviorTest.java
@@ -14,6 +14,8 @@
import cn.rukkit.config.RukkitConfig;
import cn.rukkit.game.NetworkPlayer;
import cn.rukkit.network.ConnectionHandler;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
import cn.rukkit.network.NetworkRoom;
import cn.rukkit.network.RoomConnection;
import cn.rukkit.network.command.GameCommand;
@@ -37,7 +39,8 @@
*/
class ServerRoomConnectionBehaviorTest {
private final List channels = new ArrayList<>();
- private final List handlers = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private final List legacyHandlers = new ArrayList<>();
private Object previousConfig;
private Object previousRound;
private Object previousThreadManager;
@@ -56,7 +59,10 @@ void installTestConfiguration() throws ReflectiveOperationException {
@AfterEach
void restoreConfiguration() throws ReflectiveOperationException {
- for (ConnectionHandler handler : handlers) {
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (ConnectionHandler handler : legacyHandlers) {
handler.stopTimeout();
}
for (EmbeddedChannel channel : channels) {
@@ -155,14 +161,23 @@ private static void assertEquivalent(EmbeddedChannel legacyChannel,
assertArrayEquals(legacyPacket.bytes, migratedPacket.bytes);
}
- private ConnectionHandler newHandler() {
- ConnectionHandler handler = new ConnectionHandler();
+ private ServerConnectionHandler newHandler() {
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ new ServerPacketHandlerManager());
EmbeddedChannel channel = new EmbeddedChannel(handler);
channels.add(channel);
handlers.add(handler);
return handler;
}
+ private ConnectionHandler newLegacyHandler() {
+ ConnectionHandler handler = new ConnectionHandler();
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ channels.add(channel);
+ legacyHandlers.add(handler);
+ return handler;
+ }
+
private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
Field field = Rukkit.class.getDeclaredField(name);
field.setAccessible(true);
@@ -177,7 +192,7 @@ private final class LegacyFixture {
private LegacyFixture() {
NetworkRoom room = new NetworkRoom(1);
- ConnectionHandler handler = newHandler();
+ ConnectionHandler handler = newLegacyHandler();
channel = channels.get(channels.size() - 1);
connection = new RoomConnection(handler, room);
connection.player = new NetworkPlayer(connection);
@@ -191,7 +206,7 @@ private final class MigratedFixture {
private MigratedFixture() {
ServerRoom room = new ServerRoom(1);
- ConnectionHandler handler = newHandler();
+ ServerConnectionHandler handler = newHandler();
channel = channels.get(channels.size() - 1);
connection = new ServerRoomConnection(handler, room);
connection.player = new NetworkPlayer(connection);
From 489422a351c429fce7af54920948b96d4eebec73 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Fri, 7 Aug 2026 16:36:48 +0800
Subject: [PATCH 5/9] feat(network): migrate player registration handler
---
.../java/cn/rukkit/game/NetworkPlayer.java | 39 +++-
.../core/handler/ServerPlayerInfoHandler.java | 170 ++++++++++++++
.../room/ServerGlobalConnectionManager.java | 117 ++++++++++
.../network/room/ServerRoomManager.java | 17 +-
.../ServerPlayerInfoHandlerBehaviorTest.java | 221 ++++++++++++++++++
5 files changed, 544 insertions(+), 20 deletions(-)
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
create mode 100644 src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
create mode 100644 src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
diff --git a/src/main/java/cn/rukkit/game/NetworkPlayer.java b/src/main/java/cn/rukkit/game/NetworkPlayer.java
index d229a7f..01a0153 100644
--- a/src/main/java/cn/rukkit/game/NetworkPlayer.java
+++ b/src/main/java/cn/rukkit/game/NetworkPlayer.java
@@ -69,9 +69,7 @@ public NetworkPlayer(RoomConnection connection) {
}
public NetworkPlayer(ServerRoomConnection connection) {
- this.serverConnection = connection;
- this.serverRoom = connection.currectRoom;
- this.isEmpty = false;
+ bindServerConnection(connection);
}
public NetworkPlayer() {
@@ -86,6 +84,12 @@ public RoomConnection getConnection() {
public ServerRoomConnection getServerConnection() {
return this.serverConnection;
}
+
+ public void bindServerConnection(ServerRoomConnection connection) {
+ this.serverConnection = connection;
+ this.serverRoom = connection == null ? null : connection.currectRoom;
+ this.isEmpty = connection == null;
+ }
public NetworkRoom getRoom() {
return this.room;
@@ -329,10 +333,10 @@ public void loadPlayerData() {
try {
if (dataFile.exists()) {
log.debug("Player exists.Loading...");
- data = yaml.load(new FileInputStream(dataFile));
- data.lastUsedName = name;
- data.lastConnectedTime = new Date().toString();
- data.lastConnectedAddress = connection.handler.ctx.channel().remoteAddress().toString();
+ data = yaml.load(new FileInputStream(dataFile));
+ data.lastUsedName = name;
+ data.lastConnectedTime = new Date().toString();
+ data.lastConnectedAddress = getConnectionAddress();
Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(dataFile), StandardCharsets.UTF_8));
writer.write(yaml.dumpAs(data, Tag.MAP, DumperOptions.FlowStyle.BLOCK));
writer.flush();
@@ -340,11 +344,11 @@ public void loadPlayerData() {
} else {
log.info("New player.Creating data file...");
dataFile.createNewFile();
- data = new NetworkPlayerData();
- data.uuid = uuid;
- data.lastUsedName = name;
- data.lastConnectedTime = new Date().toString();
- data.lastConnectedAddress = connection.handler.ctx.channel().remoteAddress().toString();
+ data = new NetworkPlayerData();
+ data.uuid = uuid;
+ data.lastUsedName = name;
+ data.lastConnectedTime = new Date().toString();
+ data.lastConnectedAddress = getConnectionAddress();
Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(dataFile), StandardCharsets.UTF_8));
writer.write(yaml.dumpAs(data, Tag.MAP, DumperOptions.FlowStyle.BLOCK));
writer.flush();
@@ -356,4 +360,15 @@ public void loadPlayerData() {
}
}
+
+ private String getConnectionAddress() {
+ if (serverConnection != null && serverConnection.handler != null
+ && serverConnection.handler.ctx != null) {
+ return String.valueOf(serverConnection.handler.ctx.channel().remoteAddress());
+ }
+ if (connection != null && connection.handler != null && connection.handler.ctx != null) {
+ return String.valueOf(connection.handler.ctx.channel().remoteAddress());
+ }
+ return "Unknown";
+ }
}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
new file mode 100644
index 0000000..4406492
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.event.player.PlayerJoinEvent;
+import cn.rukkit.event.player.PlayerReconnectEvent;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.util.LangUtil;
+
+import java.util.List;
+import java.util.Objects;
+
+/** Master-compatible player registration handler for the core packet stack. */
+public class ServerPlayerInfoHandler extends ServerPacketHandler {
+ private final ServerRoomManager roomManager;
+ private final ServerGlobalConnectionManager globalConnectionManager;
+
+ public ServerPlayerInfoHandler(ServerRoomManager roomManager,
+ ServerGlobalConnectionManager globalConnectionManager) {
+ this.roomManager = Objects.requireNonNull(roomManager, "roomManager must not be null");
+ this.globalConnectionManager = Objects.requireNonNull(
+ globalConnectionManager, "globalConnectionManager must not be null");
+ }
+
+ @Override
+ public int getType() {
+ return PacketType.PLAYER_INFO;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.PRE_REGISTERED);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ GameInputStream input = new GameInputStream(packet);
+ String packageName = input.readString();
+ getLogger().debug("Ints:{}", input.readInt());
+ int gameVersionCode = input.readInt();
+ input.readInt();
+ String playerName = input.readString();
+ input.readByte();
+ input.readString();
+ String uuid = input.readString();
+ int coreUnitCheck = input.readInt();
+ input.readString();
+ getLogger().debug("Got Player(package={}, version={}, name={}, uuid={}, coreUnit={})",
+ packageName, gameVersionCode, playerName, uuid, coreUnitCheck);
+
+ ServerRoom room = roomManager.getAvailableRoom();
+ NetworkPlayer targetPlayer = globalConnectionManager.getAllPlayerByUUID(uuid);
+ ServerRoom currentRoom;
+ if (targetPlayer != null && Rukkit.getConfig().syncEnabled) {
+ currentRoom = targetPlayer.getServerRoom();
+ getLogger().info("Found offline room {}", currentRoom);
+ } else {
+ currentRoom = room;
+ }
+ context.handler().setCurrentRoom(currentRoom);
+
+ if (currentRoom == null) {
+ context.ctx().writeAndFlush(UniversalPacket.kick(LangUtil.getString("rukkit.gameFull")));
+ return;
+ }
+
+ if (!currentRoom.isGaming() && targetPlayer != null) {
+ getLogger().info("Dup player {} (UUID={}) joined!", playerName, uuid);
+ if (Rukkit.getConfig().isDebug) {
+ getLogger().info("You are in the debug mode, allowing this situation!");
+ targetPlayer = null;
+ } else {
+ context.ctx().writeAndFlush(UniversalPacket.kick("You are already in server!"));
+ return;
+ }
+ }
+
+ context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config));
+
+ ServerRoomConnection connection = new ServerRoomConnection(context.handler(), currentRoom);
+ if (targetPlayer != null && Rukkit.getConfig().syncEnabled) {
+ connection.player = targetPlayer;
+ connection.player.name = playerName;
+ connection.player.bindServerConnection(connection);
+ } else {
+ NetworkPlayer player = new NetworkPlayer(connection);
+ player.name = playerName;
+ player.uuid = uuid;
+ connection.player = player;
+ }
+ context.bindConnection(connection);
+
+ if (currentRoom.connectionManager.size() <= 0) {
+ connection.sendServerMessage(LangUtil.getString("rukkit.playerGotAdmin"));
+ connection.player.isAdmin = true;
+ context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config, true));
+ } else {
+ context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config));
+ }
+
+ if (currentRoom.isGaming()) {
+ if (Rukkit.getConfig().syncEnabled) {
+ getLogger().info("Start Syncing!");
+ context.handler().stopTimeout();
+ connection.player.updateServerInfo();
+ currentRoom.connectionManager.set(connection, connection.player.playerIndex);
+ connection.startTeamTask();
+ connection.updateTeamList(false);
+ connection.startPingTask();
+ connection.handler.ctx.writeAndFlush(UniversalPacket.startGame());
+ currentRoom.syncGame();
+ connection.player.isDisconnected = false;
+ PlayerReconnectEvent.getListenerList().callListeners(
+ new PlayerReconnectEvent(connection.player));
+ } else {
+ context.ctx().writeAndFlush(UniversalPacket.kick(LangUtil.getString("rukkit.gameStarted")));
+ return;
+ }
+ }
+
+ globalConnectionManager.add(connection);
+ if (targetPlayer == null) {
+ currentRoom.connectionManager.add(connection);
+ }
+
+ try {
+ connection.player.loadPlayerData();
+ } catch (Exception e) {
+ getLogger().warn("Player {} data load failed!", playerName, e);
+ }
+ String simpleUuid = uuid.length() > 7 ? uuid.substring(0, 7) : uuid;
+ connection.sendServerMessage(LangUtil.getFormatString("rukkit.room", currentRoom.roomId));
+ connection.sendServerMessage(Rukkit.getConfig().welcomeMsg
+ .replace("{playerName}", playerName)
+ .replace("{simpleUUID}", simpleUuid)
+ .replace("{packageName}", packageName)
+ .replace("{versionCode}", String.valueOf(gameVersionCode)));
+
+ if (targetPlayer == null) {
+ connection.startPingTask();
+ connection.startTeamTask();
+ connection.updateTeamList(false);
+ context.handler().stopTimeout();
+ PlayerJoinEvent.getListenerList().callListeners(new PlayerJoinEvent(connection.player));
+ }
+
+ if (currentRoom.isGaming()) {
+ context.transitionTo(ConnectionState.IN_GAME);
+ } else {
+ context.transitionTo(ConnectionState.IN_ROOM);
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java b/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
new file mode 100644
index 0000000..3f73163
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.room;
+
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.ChannelGroupFuture;
+import io.netty.channel.group.ChannelMatcher;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Global connection registry for the migrated room runtime. */
+public class ServerGlobalConnectionManager {
+ private final List connections = new ArrayList<>();
+ private final ChannelGroup channelGroup;
+ private final ServerRoomManager roomManager;
+ private final Logger log = LoggerFactory.getLogger(ServerGlobalConnectionManager.class);
+
+ public ServerGlobalConnectionManager(ServerRoomManager roomManager) {
+ this.roomManager = roomManager;
+ this.channelGroup = new DefaultChannelGroup("ServerChannelGroups", GlobalEventExecutor.INSTANCE);
+ }
+
+ public void add(ServerRoomConnection connection) {
+ connections.add(connection);
+ channelGroup.add(connection.handler.ctx.channel());
+ }
+
+ public ChannelGroupFuture broadcast(Packet packet) {
+ return channelGroup.writeAndFlush(packet);
+ }
+
+ public ChannelGroupFuture broadcast(Packet packet, ChannelMatcher matcher) {
+ return channelGroup.writeAndFlush(packet, matcher);
+ }
+
+ public ChannelGroup flush() {
+ return channelGroup.flush();
+ }
+
+ public boolean discard(ServerRoomConnection connection) {
+ connection.handler.ctx.disconnect();
+ connections.remove(connection);
+ return channelGroup.remove(connection.handler.ctx.channel());
+ }
+
+ public ChannelGroupFuture disconnect() {
+ return channelGroup.disconnect();
+ }
+
+ public ChannelGroupFuture disconnect(ChannelMatcher matcher) {
+ return channelGroup.disconnect(matcher);
+ }
+
+ public boolean contains(ServerRoomConnection connection) {
+ return channelGroup.contains(connection.handler.ctx.channel());
+ }
+
+ public int size() {
+ return channelGroup.size();
+ }
+
+ public List getConnections() {
+ return connections;
+ }
+
+ public NetworkPlayer getPlayerByName(String name) {
+ for (ServerRoomConnection connection : connections) {
+ if (connection.player.name.equals(name)) {
+ return connection.player;
+ }
+ }
+ return null;
+ }
+
+ public NetworkPlayer getPlayerByUUID(String uuid) {
+ for (ServerRoomConnection connection : connections) {
+ if (connection.player.uuid.equals(uuid)) {
+ return connection.player;
+ }
+ }
+ return null;
+ }
+
+ public NetworkPlayer getAllPlayerByUUID(String uuid) {
+ for (ServerRoom room : roomManager.roomList) {
+ NetworkPlayer player = room.playerManager.getPlayerByUUID(uuid);
+ if (player != null && !player.isEmpty) {
+ return player;
+ }
+ }
+ return null;
+ }
+
+ public void broadcastGlobalServerMessage(String message) {
+ try {
+ broadcast(UniversalPacket.chat("SERVER", message, -1));
+ } catch (IOException ignored) {
+ log.debug("Unable to create global server message packet", ignored);
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomManager.java b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
index 905a34b..6968f47 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
@@ -30,18 +30,19 @@ public ServerRoomManager(RoundConfig defaultConfig, int maxRoom) {
resetAllRooms();
}
- /**
- * Kept as a compatibility placeholder because the master implementation
- * does not register connections through the room manager yet.
- */
public void addConnection(ServerRoomConnection connection, int roomId) {
+ getRoom(roomId).connectionManager.add(connection);
}
- /**
- * Kept as a compatibility placeholder because the master implementation
- * does not register connections through the room manager yet.
- */
public void addConnection(ServerRoomConnection connection) {
+ if (connection.currectRoom != null) {
+ connection.currectRoom.connectionManager.add(connection);
+ return;
+ }
+ ServerRoom room = getAvailableRoom();
+ if (room != null) {
+ room.connectionManager.add(connection);
+ }
}
public ServerRoom getDefaultRoom() {
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
new file mode 100644
index 0000000..3a787ad
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.mod.ModManager;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerPlayerInfoHandlerBehaviorTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private Object previousModManager;
+ private ThreadManager testThreadManager;
+ private ServerRoomManager roomManager;
+ private ServerGlobalConnectionManager globalConnectionManager;
+ private boolean modsDirectoryExisted;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 1;
+ config.maxPlayer = 2;
+ config.syncEnabled = true;
+ config.isDebug = false;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(4);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+
+ File modsDirectory = new File(Rukkit.getEnvPath(), "mods");
+ modsDirectoryExisted = modsDirectory.isDirectory();
+ previousModManager = setStatic("modManager", new ModManager());
+
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 1);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ setStatic("modManager", previousModManager);
+
+ File modsDirectory = new File(Rukkit.getEnvPath(), "mods");
+ if (!modsDirectoryExisted && modsDirectory.isDirectory()) {
+ modsDirectory.delete();
+ }
+ }
+
+ @Test
+ void registersFirstPlayerIntoNewRoomAndGlobalRegistry() throws Exception {
+ ConnectionFixture fixture = registerPlayer("Alice", "uuid-first");
+
+ ServerRoomConnection connection = fixture.handler.getConn();
+ assertNotNull(connection);
+ assertEquals(1, fixture.room.playerManager.getPlayerCount());
+ assertEquals(1, globalConnectionManager.size());
+ assertEquals("Alice", connection.player.name);
+ assertEquals("uuid-first", connection.player.uuid);
+ assertTrue(connection.player.isAdmin);
+ assertEquals(ConnectionState.IN_ROOM, fixture.handler.getState());
+
+ List packetTypes = drainPacketTypes(fixture.channel);
+ assertTrue(packetTypes.contains(PacketType.SERVER_INFO));
+ assertTrue(packetTypes.contains(PacketType.TEAM_LIST));
+ assertTrue(packetTypes.contains(PacketType.SEND_CHAT));
+ }
+
+ @Test
+ void rejectsDuplicatePlayerWhileRoomIsNotGaming() throws Exception {
+ ConnectionFixture first = registerPlayer("Alice", "uuid-duplicate");
+ drainPacketTypes(first.channel);
+
+ ConnectionFixture duplicate = newConnection();
+ duplicate.handler.setState(ConnectionState.PRE_REGISTERED);
+ duplicate.channel.writeInbound(playerInfoPacket("Alice-2", "uuid-duplicate"));
+
+ assertNull(duplicate.handler.getConn());
+ assertEquals(1, globalConnectionManager.size());
+ assertEquals(1, duplicate.room.playerManager.getPlayerCount());
+ assertTrue(drainPacketTypes(duplicate.channel).contains(PacketType.KICK));
+ }
+
+ @Test
+ void reconnectsDisconnectedPlayerToTheNewConnectionInGame() throws Exception {
+ TestServerRoom room = new TestServerRoom(0);
+ roomManager.roomList.set(0, room);
+ ConnectionFixture first = registerPlayer("Alice", "uuid-reconnect");
+ NetworkPlayer player = first.handler.getConn().player;
+
+ room.currentStep = 10;
+ first.channel.close();
+
+ assertEquals(0, globalConnectionManager.size());
+ assertEquals(0, room.connectionManager.size());
+ assertTrue(player.isDisconnected);
+
+ ConnectionFixture reconnect = newConnection();
+ reconnect.handler.setState(ConnectionState.PRE_REGISTERED);
+ reconnect.channel.writeInbound(playerInfoPacket("Alice-Reconnected", "uuid-reconnect"));
+
+ assertSame(player, reconnect.handler.getConn().player);
+ assertSame(reconnect.handler.getConn(), player.getServerConnection());
+ assertEquals("Alice-Reconnected", player.name);
+ assertFalse(player.isDisconnected);
+ assertEquals(1, room.connectionManager.size());
+ assertEquals(1, globalConnectionManager.size());
+ assertEquals(ConnectionState.IN_GAME, reconnect.handler.getState());
+ }
+
+ private ConnectionFixture registerPlayer(String name, String uuid) throws Exception {
+ ConnectionFixture fixture = newConnection();
+ fixture.handler.setState(ConnectionState.PRE_REGISTERED);
+ fixture.channel.writeInbound(playerInfoPacket(name, uuid));
+ return fixture;
+ }
+
+ private ConnectionFixture newConnection() {
+ ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
+ handlerManager.register(new ServerPlayerInfoHandler(roomManager, globalConnectionManager));
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ handlerManager, globalConnectionManager::discard);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+ return new ConnectionFixture(handler, channel, roomManager.getRoom(0));
+ }
+
+ private static Packet playerInfoPacket(String name, String uuid) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString("com.corrodinggames.rts");
+ output.writeInt(1);
+ output.writeInt(176);
+ output.writeInt(176);
+ output.writeString(name);
+ output.writeByte(0);
+ output.writeString("");
+ output.writeString(uuid);
+ output.writeInt(0);
+ output.writeString("");
+ return output.createPacket(PacketType.PLAYER_INFO);
+ }
+
+ private static List drainPacketTypes(EmbeddedChannel channel) {
+ List types = new ArrayList<>();
+ Packet packet;
+ while ((packet = channel.readOutbound()) != null) {
+ types.add(packet.type);
+ }
+ return types;
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ConnectionFixture(ServerConnectionHandler handler,
+ EmbeddedChannel channel,
+ ServerRoom room) {
+ }
+
+ private static final class TestServerRoom extends ServerRoom {
+ private TestServerRoom(int id) {
+ super(id);
+ }
+
+ @Override
+ public void syncGame() {
+ }
+ }
+}
From b12ae3ba666cc458a60402650a852abbfc9f72d7 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Mon, 10 Aug 2026 13:57:19 +0800
Subject: [PATCH 6/9] feat(network): stabilize tick dispatch and packet blocks
---
.../java/cn/rukkit/game/NetworkPlayer.java | 58 ++++-
.../cn/rukkit/network/GameInputStream.java | 10 +
.../cn/rukkit/network/GameOutputStream.java | 39 ++-
.../java/cn/rukkit/network/NetworkRoom.java | 144 ++++++++---
.../java/cn/rukkit/network/NetworkTick.java | 30 +++
.../cn/rukkit/network/RoomConnection.java | 2 +
.../network/core/packet/UniversalPacket.java | 51 +++-
.../cn/rukkit/network/io/GameInputStream.java | 10 +
.../java/cn/rukkit/network/packet/Packet.java | 31 ++-
.../rukkit/network/room/RoomCommandQueue.java | 81 +++++++
.../java/cn/rukkit/service/ThreadManager.java | 10 +
.../network/NetworkTickBehaviorTest.java | 223 ++++++++++++++++++
.../network/room/RoomCommandQueueTest.java | 83 +++++++
13 files changed, 687 insertions(+), 85 deletions(-)
create mode 100644 src/main/java/cn/rukkit/network/NetworkTick.java
create mode 100644 src/main/java/cn/rukkit/network/room/RoomCommandQueue.java
create mode 100644 src/test/java/cn/rukkit/network/NetworkTickBehaviorTest.java
create mode 100644 src/test/java/cn/rukkit/network/room/RoomCommandQueueTest.java
diff --git a/src/main/java/cn/rukkit/game/NetworkPlayer.java b/src/main/java/cn/rukkit/game/NetworkPlayer.java
index 01a0153..dfe315f 100644
--- a/src/main/java/cn/rukkit/game/NetworkPlayer.java
+++ b/src/main/java/cn/rukkit/game/NetworkPlayer.java
@@ -30,6 +30,9 @@
public class NetworkPlayer
{
+ private static final long HEARTBEAT_TIMEOUT_MILLIS = 15_000L;
+ private static final long AFK_TIMEOUT_MILLIS = 60_000L;
+
NetworkPlayerData data;
public String name = "Player - Empty";
@@ -56,7 +59,54 @@ public class NetworkPlayer
public boolean isSurrounded = false;
public boolean isDisconnected = false;
- public boolean isAfk = false;
+ public volatile boolean isAfk = false;
+ private volatile long lastHeartbeatAt = -1L;
+ private volatile long lastCommandAt = -1L;
+
+ /**
+ * Returns whether this player contributes its index to a game command's
+ * shared-control mask.
+ */
+ public boolean isSharingControlForCommands() {
+ return isSharingControl || isSharingControlDueAfk();
+ }
+
+ /** Returns the automatic share state advertised in the team list. */
+ public boolean isSharingControlDueAfk() {
+ long now = System.currentTimeMillis();
+ boolean heartbeatTimedOut = lastHeartbeatAt > 0
+ && now - lastHeartbeatAt >= HEARTBEAT_TIMEOUT_MILLIS;
+ boolean commandTimedOut = lastCommandAt > 0
+ && now - lastCommandAt >= AFK_TIMEOUT_MILLIS;
+ if (commandTimedOut) {
+ isAfk = true;
+ }
+ return isDisconnected || isAfk || heartbeatTimedOut || commandTimedOut;
+ }
+
+ /** Records a response to the server heartbeat used by the original AFK detector. */
+ public void recordHeartbeat() {
+ lastHeartbeatAt = System.currentTimeMillis();
+ }
+
+ /** Starts tracking command activity for a new game. */
+ public void beginGameActivityTracking() {
+ lastCommandAt = System.currentTimeMillis();
+ isAfk = false;
+ }
+
+ /** Records a normal command from this player and clears automatic AFK sharing. */
+ public void markCommandActivity() {
+ lastCommandAt = System.currentTimeMillis();
+ isAfk = false;
+ }
+
+ /** Stops game-local AFK tracking when the room returns to the lobby. */
+ public void endGameActivityTracking() {
+ lastHeartbeatAt = -1L;
+ lastCommandAt = -1L;
+ isAfk = false;
+ }
public CheckSumList checkList = new CheckSumList();
private NetworkRoom room;
@@ -175,7 +225,7 @@ public void writePlayer(DataOutputStream stream, boolean simpleMode) throws IOEx
stream.writeByte(0);
stream.writeInt(ping);
stream.writeBoolean(isSharingControl);
- stream.writeBoolean(isDisconnected || isAfk);
+ stream.writeBoolean(isSharingControlDueAfk());
} else {
//玩家位置
stream.writeByte(playerIndex);
@@ -208,8 +258,8 @@ public void writePlayer(DataOutputStream stream, boolean simpleMode) throws IOEx
//分享控制
stream.writeBoolean(isSharingControl);
- //是否掉线
- stream.writeBoolean(false);
+ //因 AFK/掉线自动分享控制
+ stream.writeBoolean(isSharingControlDueAfk());
//是否投降
stream.writeBoolean(isSurrounded);
diff --git a/src/main/java/cn/rukkit/network/GameInputStream.java b/src/main/java/cn/rukkit/network/GameInputStream.java
index 714b2d8..b1748b3 100644
--- a/src/main/java/cn/rukkit/network/GameInputStream.java
+++ b/src/main/java/cn/rukkit/network/GameInputStream.java
@@ -86,6 +86,16 @@ public byte[] readStreamBytes() throws IOException {
return arrby;
}
+ /** Reads a named, uncompressed block and returns only its payload. */
+ public byte[] getBlockRaw(String expectedName) throws IOException {
+ String actualName = this.readString();
+ if (!expectedName.equals(actualName)) {
+ throw new IOException("unexpected block name: expected "
+ + expectedName + ", got " + actualName);
+ }
+ return readStreamBytes();
+ }
+
public DataInputStream getDecodeStream() throws IOException{
String blockName = this.readString();
// LoggerFactory.getLogger("GameInputStream").info("BlockName: {}", blockName);
diff --git a/src/main/java/cn/rukkit/network/GameOutputStream.java b/src/main/java/cn/rukkit/network/GameOutputStream.java
index 50affdf..76f2368 100644
--- a/src/main/java/cn/rukkit/network/GameOutputStream.java
+++ b/src/main/java/cn/rukkit/network/GameOutputStream.java
@@ -20,6 +20,7 @@ public class GameOutputStream
public DataOutputStream stream = new DataOutputStream(buffer);
public DataOutputStream currentStream = new DataOutputStream(buffer);
public LinkedList blockQuere = new LinkedList();
+ private LinkedList parentStreams = new LinkedList();
private GZIPOutputStream gzipStream;
@@ -116,39 +117,29 @@ public void writeEnum(Enum clazz) throws IOException {
*/
public void startBlock(String blockName, boolean isGzip) throws IOException {
GzipEncoder enc = getEncodeStream(blockName, isGzip);
+ parentStreams.addLast(stream);
+ blockQuere.addLast(enc);
currentStream = stream;
stream = enc.stream;
- blockQuere.addLast(enc);
- OutputStream outputStream;
- if (isGzip) {
- this.gzipStream = new GZIPOutputStream(this.buff);
- this.bufferedStream = new BufferedOutputStream(this.gzipStream);
- outputStream = this.bufferedStream;
- } else {
- outputStream = this.buff;
- }
- stream = new DataOutputStream(outputStream);
}
/*
* End a content block.
*/
public void endBlock() throws IOException {
- if (blockQuere.size() != 0) {
- GzipEncoder enc = blockQuere.removeLast();
- //enc.stream = stream;
- //enc.stream.write(stream.
- currentStream.writeUTF(enc.str);
- currentStream.writeInt(stream.size());
- buff.writeTo((OutputStream)this.currentStream);
- buff.flush();
- //detect next block
- if (blockQuere.size() == 0) {
- stream = currentStream;
- } else {
- stream = blockQuere.getLast().stream;
- }
+ if (blockQuere.isEmpty() || parentStreams.isEmpty()) {
+ throw new IllegalStateException("no open block");
}
+
+ GzipEncoder enc = blockQuere.removeLast();
+ DataOutputStream parent = parentStreams.removeLast();
+ enc.flush();
+ parent.writeUTF(enc.str);
+ parent.writeInt(enc.buffer.size());
+ enc.buffer.writeTo(parent);
+ parent.flush();
+ stream = parent;
+ currentStream = parent;
}
}
diff --git a/src/main/java/cn/rukkit/network/NetworkRoom.java b/src/main/java/cn/rukkit/network/NetworkRoom.java
index 35e4171..ea71e21 100644
--- a/src/main/java/cn/rukkit/network/NetworkRoom.java
+++ b/src/main/java/cn/rukkit/network/NetworkRoom.java
@@ -8,6 +8,7 @@
import cn.rukkit.game.*;
import cn.rukkit.network.command.GameCommand;
import cn.rukkit.network.packet.Packet;
+import cn.rukkit.network.room.RoomCommandQueue;
import cn.rukkit.util.Vote;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,22 +16,25 @@
import java.io.IOException;
import java.text.MessageFormat;
import java.util.HashMap;
-import java.util.LinkedList;
+import java.util.List;
import java.util.Random;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicInteger;
public class NetworkRoom {
+ private static final Logger commandLog = LoggerFactory.getLogger(NetworkRoom.class);
public PlayerManager playerManager;
public RoomConnectionManager connectionManager;
/**
* 命令列表。在采用更稳定的同步(useCommandQuere)时会启用,减少同步错误但是会提高操作延迟。
*/
- private LinkedList commandQuere = new LinkedList();
+ private final RoomCommandQueue commandQuere = new RoomCommandQueue();
+ private final Object commandDispatchLock = new Object();
public RoundConfig config;
- public int stepRate = 200;
- public int currentStep = 0;
+ /** Approximate network window in milliseconds; scheduling uses nanoseconds. */
+ public int stepRate = NetworkTick.WINDOW_PERIOD_MILLIS;
+ public volatile int currentStep = 0;
public int checkSumFrame = 0;
public final AtomicInteger checkSumReceived = new AtomicInteger();
public int syncCount = 0;
@@ -43,7 +47,7 @@ public class NetworkRoom {
*/
public SaveData lastNoStopSave;
private boolean isGaming = false;
- private boolean isPaused = false;
+ private volatile boolean isPaused = false;
private ScheduledFuture gameTaskFuture;
private SaveManager saveManager;
@@ -151,7 +155,7 @@ public void run() {
RukkitConfig cfg = Rukkit.getConfig();
if (!isPaused) {
// Add step
- currentStep += 10;
+ currentStep += NetworkTick.FRAMES_PER_WINDOW;
if (Rukkit.getConfig().checksumSync) {
if (currentStep % 300 == 0) {
if (!checkRequested) {
@@ -180,19 +184,7 @@ public void run() {
return;
}
- synchronized (commandQuere) {
- //log.debug("tick:" + tickTime);
- try {
- if (commandQuere.isEmpty() && !isPaused) {
- connectionManager.broadcast(Packet.emptyCommand(currentStep));
- } else {
- while(!commandQuere.isEmpty() && !isPaused){
- GameCommand cmd = commandQuere.removeLast();
- connectionManager.broadcast(Packet.gameCommand(currentStep, cmd));
- }
- }
- } catch (IOException ignored) {}
- }
+ dispatchQueuedCommands();
}
}
@@ -202,7 +194,7 @@ public void run() {
RukkitConfig cfg = Rukkit.getConfig();
if (!isPaused) {
// Add tickTime
- currentStep += 10;
+ currentStep += NetworkTick.FRAMES_PER_WINDOW;
}
// If playercount == 1 then have a sync and pauseGame;
@@ -235,19 +227,7 @@ public void run() {
}
// If using query mode:
- synchronized (commandQuere) {
- //log.debug("tick:" + tickTime);
- try {
- if (commandQuere.isEmpty() && !isPaused) {
- connectionManager.broadcast(Packet.emptyCommand(currentStep));
- } else {
- while(!commandQuere.isEmpty() && !isPaused){
- GameCommand cmd = commandQuere.removeLast();
- connectionManager.broadcast(new Packet().gameCommand(currentStep, cmd));
- }
- }
- } catch (IOException ignored) {}
- }
+ dispatchQueuedCommands();
}
}
@@ -264,6 +244,7 @@ public void run() {
try {
//Rukkit.getSaveManager().sendDefaultSaveToAll();
//Rukkit.getConnectionManager().broadcast(Packet.syncCheckSum());
+ flushQueuedCommandsForSync();
connectionManager.broadcast(Packet.sendPullSave(NetworkRoom.this));
SaveData save;
long time = System.currentTimeMillis();
@@ -299,6 +280,47 @@ public void setPaused(boolean paused) {
isPaused = paused;
}
+ /**
+ * Sends one room tick containing all commands currently pending. The
+ * dispatch lock prevents a normal tick and a resync boundary from
+ * overtaking each other, while the queue itself remains producer-safe.
+ */
+ private void dispatchQueuedCommands() {
+ synchronized (commandDispatchLock) {
+ if (isPaused) {
+ return;
+ }
+
+ List commands = commandQuere.drain();
+ try {
+ if (commands.isEmpty()) {
+ connectionManager.broadcast(Packet.emptyCommand(currentStep));
+ } else {
+ connectionManager.broadcast(Packet.gameCommands(currentStep, commands));
+ }
+ } catch (IOException e) {
+ commandQuere.prepend(commands);
+ commandLog.warn("Failed to build command tick for room {}", roomId, e);
+ }
+ }
+ }
+
+ /** Completes the pending command boundary before requesting a resync save. */
+ private void flushQueuedCommandsForSync() throws IOException {
+ synchronized (commandDispatchLock) {
+ List commands = commandQuere.drain();
+ if (commands.isEmpty()) {
+ return;
+ }
+ try {
+ connectionManager.broadcast(Packet.gameCommands(currentStep, commands));
+ } catch (IOException e) {
+ commandQuere.prepend(commands);
+ throw e;
+ }
+ }
+ }
+
public void stopGame() {
stopGame(false);
}
@@ -315,11 +337,20 @@ public void doChecksum() {
* Stop a round game.
*/
public void stopGame(boolean isRuturn) {
+ setPaused(true);
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
// Reset ticktime and checksum
currentStep = 0;
checkSumFrame = 0;
syncCount = 0;
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ if (player != null && !player.isEmpty) {
+ player.endGameActivityTracking();
+ }
+ }
// End all connections
if (isRuturn) {
try {
@@ -333,7 +364,10 @@ public void stopGame(boolean isRuturn) {
playerManager.reset();
connectionManager.disconnect();
}
- gameTaskFuture.cancel(true);
+ if (gameTaskFuture != null) {
+ gameTaskFuture.cancel(true);
+ gameTaskFuture = null;
+ }
isGaming = false;
RoomStopGameEvent.getListenerList().callListeners(new RoomStopGameEvent(this));
//Rukkit.getThreadManager().shutdown();
@@ -350,6 +384,10 @@ public void broadcast(Packet packet) {
}
public void discard() {
+ setPaused(true);
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
playerManager.reset();
connectionManager.disconnect();
connectionManager.clearAllSaveData();
@@ -376,11 +414,24 @@ public void syncGame() {
/**
* starts a round game.
*/
- public void startGame() {
+ public synchronized void startGame() {
+ if (isGaming || currentStep > 0
+ || (gameTaskFuture != null && !gameTaskFuture.isDone())) {
+ return;
+ }
try {
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
+ setPaused(false);
connectionManager.broadcast(Packet.gameStart());
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ if (player != null && !player.isEmpty) {
+ player.beginGameActivityTracking();
+ }
+ }
// Set shared control.
- if (Rukkit.getRoundConfig().sharedControl) {
+ if (config.sharedControl) {
for (NetworkPlayer p:playerManager.getPlayerArray()) {
try {
p.isNull();
@@ -396,7 +447,11 @@ public void startGame() {
conn.updateTeamList();
conn.handler.setState(ConnectionState.IN_GAME);
}
- gameTaskFuture = Rukkit.getThreadManager().schedule(new GameTask(), stepRate, stepRate);
+ gameTaskFuture = Rukkit.getThreadManager().scheduleAtFixedRate(
+ new GameTask(),
+ NetworkTick.WINDOW_PERIOD_NANOS,
+ NetworkTick.WINDOW_PERIOD_NANOS,
+ java.util.concurrent.TimeUnit.NANOSECONDS);
//connectionManager.broadcast()
isGaming = true;
RoomStartGameEvent.getListenerList().callListeners(new RoomStartGameEvent(this));
@@ -414,12 +469,15 @@ public void startGame() {
// }
public void changeMapWhileRunning(String mapName, int type) {
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
Rukkit.getRoundConfig().mapName = mapName;
Rukkit.getRoundConfig().mapType = type;
try {
connectionManager.broadcast(Packet.gameStart());
// Set shared control.
- if (Rukkit.getRoundConfig().sharedControl) {
+ if (config.sharedControl) {
for (NetworkPlayer p:playerManager.getPlayerArray()) {
try {
p.isNull();
@@ -452,8 +510,16 @@ public int getCurrentStep() {
public void addCommand(GameCommand cmd) {
if (Rukkit.getConfig().useCommandQuere) {
- commandQuere.addLast(cmd);
+ synchronized (commandDispatchLock) {
+ if (isPaused()) {
+ return;
+ }
+ commandQuere.addLast(cmd);
+ }
} else {
+ if (isPaused()) {
+ return;
+ }
try {
broadcast(Packet.gameCommand(this.currentStep, cmd));
} catch (IOException ignored) {}
diff --git a/src/main/java/cn/rukkit/network/NetworkTick.java b/src/main/java/cn/rukkit/network/NetworkTick.java
new file mode 100644
index 0000000..6f66e4f
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/NetworkTick.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find the license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network;
+
+import java.util.concurrent.TimeUnit;
+
+/** Shared timing constants for the game's network tick window. */
+public final class NetworkTick {
+ /** The original protocol advances ten simulation frames per TICK packet. */
+ public static final int FRAMES_PER_WINDOW = 10;
+
+ /**
+ * Ten frames at the original 60 simulation frames per second. The
+ * nanosecond period avoids accumulating millisecond rounding error.
+ */
+ public static final long WINDOW_PERIOD_NANOS = TimeUnit.SECONDS.toNanos(1) / 6;
+
+ /** Human-readable approximation retained for existing room diagnostics. */
+ public static final int WINDOW_PERIOD_MILLIS = 167;
+
+ private NetworkTick() {
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/RoomConnection.java b/src/main/java/cn/rukkit/network/RoomConnection.java
index ee8ed2f..25739d7 100644
--- a/src/main/java/cn/rukkit/network/RoomConnection.java
+++ b/src/main/java/cn/rukkit/network/RoomConnection.java
@@ -237,6 +237,8 @@ public void kick(String reason) {
* 心跳包返回
*/
public void pong() {
+ if (player == null) return;
player.ping = (int) (System.currentTimeMillis() - pingTime);
+ player.recordHeartbeat();
}
}
diff --git a/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
index dc9e2c8..788baa5 100644
--- a/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
+++ b/src/main/java/cn/rukkit/network/core/packet/UniversalPacket.java
@@ -26,6 +26,7 @@
import java.io.IOException;
import java.util.ArrayList;
+import java.util.List;
import java.util.Random;
/**
@@ -94,12 +95,28 @@ public static Packet preRegister() throws IOException {
}
public static Packet gameCommand(int tick, GameCommand command) throws IOException {
+ return gameCommands(tick, List.of(command));
+ }
+
+ /**
+ * Builds the server-to-client tick packet used by the original game.
+ * Each command is encoded as one raw {@code c} block inside the packet.
+ */
+ public static Packet gameCommands(int tick, List commands) throws IOException {
+ if (commands == null) {
+ throw new IllegalArgumentException("commands must not be null");
+ }
GameOutputStream output = new GameOutputStream();
output.writeInt(tick);
- output.writeInt(1);
- output.startBlock("c", false);
- output.write(command.arr);
- output.endBlock();
+ output.writeInt(commands.size());
+ for (GameCommand command : commands) {
+ if (command == null) {
+ throw new IllegalArgumentException("commands must not contain null");
+ }
+ output.startBlock("c", false);
+ output.write(command.arr);
+ output.endBlock();
+ }
return output.createPacket(PacketType.TICK);
}
@@ -111,19 +128,29 @@ public static Packet emptyCommand(int tick) throws IOException {
}
public static Packet gameStart() throws IOException {
- return startGame();
+ return startGame(Rukkit.getRoundConfig());
}
public static Packet startGame() throws IOException {
+ return startGame(Rukkit.getRoundConfig());
+ }
+
+ /** Builds a start packet from the room-local configuration. */
+ public static Packet gameStart(RoundConfig config) throws IOException {
+ return startGame(config);
+ }
+
+ /** Builds a start packet from the room-local configuration. */
+ public static Packet startGame(RoundConfig config) throws IOException {
GameOutputStream output = new GameOutputStream();
output.writeByte(0);
- if (Rukkit.getRoundConfig().mapType == 0) {
+ if (config.mapType == 0) {
output.writeInt(0);
- output.writeString("maps/skirmish/" + Rukkit.getRoundConfig().mapName + ".tmx");
- } else if (Rukkit.getRoundConfig().mapType == 1) {
+ output.writeString("maps/skirmish/" + config.mapName + ".tmx");
+ } else if (config.mapType == 1) {
output.writeInt(1);
- output.writeFile(CustomMapLoader.getStreamByName(Rukkit.getRoundConfig().mapName + ".tmx"));
- output.writeString(Rukkit.getRoundConfig().mapName + ".tmx");
+ output.writeFile(CustomMapLoader.getStreamByName(config.mapName + ".tmx"));
+ output.writeString(config.mapName + ".tmx");
}
output.writeBoolean(false);
return output.createPacket(PacketType.START_GAME);
@@ -177,7 +204,7 @@ public static Packet serverInfo(RoundConfig config, boolean isAdmin, ArrayList commands) throws IOException {
+ if (commands == null) {
+ throw new IllegalArgumentException("commands must not be null");
+ }
GameOutputStream o = new GameOutputStream();
o.writeInt(tick);
- o.writeInt(1);
- o.startBlock("c", false);
- o.stream.write(cmd.arr);
- //o.stream.write(cmd.arr);
- o.endBlock();
+ o.writeInt(commands.size());
+ for (GameCommand cmd : commands) {
+ if (cmd == null) {
+ throw new IllegalArgumentException("commands must not contain null");
+ }
+ o.startBlock("c", false);
+ o.write(cmd.arr);
+ o.endBlock();
+ }
return (o.createPacket(10));
}
@@ -235,7 +250,7 @@ public static Packet serverInfo(RoundConfig config, boolean isAdmin, ArrayListThe queue is swapped while holding the lock and drained outside the
+ * critical section. This keeps command producers independent from packet
+ * serialization while preserving the order in which commands were enqueued.
+ * The queue owns the lifecycle of pending commands; callers must not mutate
+ * the returned list after handing it to a packet builder.
+ */
+public final class RoomCommandQueue {
+ private ArrayDeque pending = new ArrayDeque<>();
+
+ /** Adds a command to the tail of the room FIFO. */
+ public void addLast(GameCommand command) {
+ Objects.requireNonNull(command, "command");
+ synchronized (this) {
+ pending.addLast(command);
+ }
+ }
+
+ /**
+ * Atomically takes all commands currently pending for a tick.
+ * Commands added after the swap belong to the following tick.
+ */
+ public List drain() {
+ ArrayDeque batch;
+ synchronized (this) {
+ if (pending.isEmpty()) {
+ return List.of();
+ }
+ batch = pending;
+ pending = new ArrayDeque<>();
+ }
+ return new ArrayList<>(batch);
+ }
+
+ /**
+ * Puts a failed batch back at the head, retaining its original order.
+ */
+ public void prepend(List commands) {
+ if (commands == null || commands.isEmpty()) {
+ return;
+ }
+ synchronized (this) {
+ for (int i = commands.size() - 1; i >= 0; i--) {
+ pending.addFirst(Objects.requireNonNull(commands.get(i), "command"));
+ }
+ }
+ }
+
+ /** Removes all commands that have not yet been dispatched. */
+ public synchronized void clear() {
+ pending.clear();
+ }
+
+ public synchronized boolean isEmpty() {
+ return pending.isEmpty();
+ }
+
+ public synchronized int size() {
+ return pending.size();
+ }
+}
diff --git a/src/main/java/cn/rukkit/service/ThreadManager.java b/src/main/java/cn/rukkit/service/ThreadManager.java
index 4e32742..8f25606 100644
--- a/src/main/java/cn/rukkit/service/ThreadManager.java
+++ b/src/main/java/cn/rukkit/service/ThreadManager.java
@@ -44,6 +44,16 @@ public ScheduledFuture schedule(Runnable runnable, int initialDelay, int delay)
ScheduledFuture t = executorService.scheduleWithFixedDelay(runnable, initialDelay, delay,TimeUnit.MILLISECONDS);
return t;
}
+
+ /**
+ * Schedule a periodic task against the executor's monotonic fixed-rate
+ * clock. Unlike {@link #schedule(Runnable, int, int)}, the task period is
+ * measured from scheduled deadlines rather than from task completion.
+ */
+ public ScheduledFuture> scheduleAtFixedRate(Runnable runnable,
+ long initialDelay, long period, TimeUnit unit) {
+ return executorService.scheduleAtFixedRate(runnable, initialDelay, period, unit);
+ }
/**
* Schedule a task without schedule.
diff --git a/src/test/java/cn/rukkit/network/NetworkTickBehaviorTest.java b/src/test/java/cn/rukkit/network/NetworkTickBehaviorTest.java
new file mode 100644
index 0000000..54d38e6
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/NetworkTickBehaviorTest.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find the license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.game.mod.ModManager;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.IntSupplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NetworkTickBehaviorTest {
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private Object previousDefaultSave;
+ private Object previousModManager;
+ private ThreadManager testThreadManager;
+ private final List channels = new ArrayList<>();
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.singlePlayerMode = true;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ previousModManager = setStatic("modManager", new ModManager());
+ testThreadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ SaveData defaultSave = new SaveData();
+ defaultSave.arr = new byte[0];
+ previousDefaultSave = setStatic("defaultSave", defaultSave);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("modManager", previousModManager);
+ setStatic("threadManager", previousThreadManager);
+ setStatic("defaultSave", previousDefaultSave);
+ }
+
+ @Test
+ void usesTheOriginalTenFrameNetworkWindow() {
+ assertEquals(10, NetworkTick.FRAMES_PER_WINDOW);
+ assertEquals(TimeUnit.SECONDS.toNanos(1) / 6,
+ NetworkTick.WINDOW_PERIOD_NANOS);
+ assertEquals(167, NetworkTick.WINDOW_PERIOD_MILLIS);
+ }
+
+ @Test
+ void bothRoomImplementationsAdvanceTenFramesPerRunningWindow() {
+ LegacyFixture legacy = newLegacyFixture();
+ MigratedFixture migrated = newMigratedFixture();
+
+ legacy.room.new GameTask().run();
+ migrated.room.new GameTask().run();
+
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, legacy.room.getCurrentStep());
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, migrated.room.getCurrentStep());
+ }
+
+ @Test
+ void pausedRoomDoesNotAdvanceAndResumesAtTheSameWindowSize() {
+ LegacyFixture legacy = newLegacyFixture();
+ MigratedFixture migrated = newMigratedFixture();
+ legacy.room.setPaused(true);
+ migrated.room.setPaused(true);
+
+ legacy.room.new GameTask().run();
+ migrated.room.new GameTask().run();
+
+ assertEquals(0, legacy.room.getCurrentStep());
+ assertEquals(0, migrated.room.getCurrentStep());
+
+ legacy.room.setPaused(false);
+ migrated.room.setPaused(false);
+ legacy.room.new GameTask().run();
+ migrated.room.new GameTask().run();
+
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, legacy.room.getCurrentStep());
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, migrated.room.getCurrentStep());
+ }
+
+ @Test
+ void noStopTaskUsesTheSameWindowSize() {
+ LegacyFixture legacy = newLegacyFixture();
+ MigratedFixture migrated = newMigratedFixture();
+
+ legacy.room.new NonStopGameTask().run();
+ migrated.room.new NonStopGameTask().run();
+
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, legacy.room.getCurrentStep());
+ assertEquals(NetworkTick.FRAMES_PER_WINDOW, migrated.room.getCurrentStep());
+ }
+
+ @Test
+ void stoppingEitherRoomCancelsFutureTickAdvancement() throws Exception {
+ LegacyFixture legacy = newLegacyFixture();
+ MigratedFixture migrated = newMigratedFixture();
+
+ legacy.room.startGame();
+ migrated.room.startGame();
+ awaitAtLeast(legacy.room::getCurrentStep, NetworkTick.FRAMES_PER_WINDOW);
+ awaitAtLeast(migrated.room::getCurrentStep, NetworkTick.FRAMES_PER_WINDOW);
+
+ legacy.room.stopGame();
+ migrated.room.stopGame();
+ assertEquals(0, legacy.room.getCurrentStep());
+ assertEquals(0, migrated.room.getCurrentStep());
+
+ Thread.sleep(NetworkTick.WINDOW_PERIOD_MILLIS * 2L);
+ assertEquals(0, legacy.room.getCurrentStep());
+ assertEquals(0, migrated.room.getCurrentStep());
+ }
+
+ @Test
+ void startingTheSameRoomTwiceDoesNotRegisterASecondTicker()
+ throws Exception {
+ LegacyFixture legacy = newLegacyFixture();
+ MigratedFixture migrated = newMigratedFixture();
+
+ legacy.room.startGame();
+ migrated.room.startGame();
+ Object legacyTask = taskFuture(NetworkRoom.class, legacy.room);
+ Object migratedTask = taskFuture(ServerRoom.class, migrated.room);
+
+ legacy.room.startGame();
+ migrated.room.startGame();
+
+ assertSame(legacyTask, taskFuture(NetworkRoom.class, legacy.room));
+ assertSame(migratedTask, taskFuture(ServerRoom.class, migrated.room));
+
+ legacy.room.stopGame();
+ migrated.room.stopGame();
+ }
+
+ private LegacyFixture newLegacyFixture() {
+ NetworkRoom room = new NetworkRoom(1);
+ ConnectionHandler handler = new ConnectionHandler();
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ channels.add(channel);
+ RoomConnection connection = new RoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ room.connectionManager.add(connection);
+ return new LegacyFixture(room, channel);
+ }
+
+ private MigratedFixture newMigratedFixture() {
+ ServerRoom room = new ServerRoom(1);
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ new ServerPacketHandlerManager());
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ channels.add(channel);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ room.connectionManager.add(connection);
+ return new MigratedFixture(room, channel);
+ }
+
+ private static void awaitAtLeast(IntSupplier currentStep, int expected)
+ throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (currentStep.getAsInt() < expected && System.nanoTime() < deadline) {
+ Thread.sleep(5);
+ }
+ assertTrue(currentStep.getAsInt() >= expected,
+ "room did not emit its first network tick in time");
+ }
+
+ private static Object setStatic(String name, Object value)
+ throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private static Object taskFuture(Class> roomType, Object room)
+ throws ReflectiveOperationException {
+ Field field = roomType.getDeclaredField("gameTaskFuture");
+ field.setAccessible(true);
+ return field.get(room);
+ }
+
+ private record LegacyFixture(NetworkRoom room, EmbeddedChannel channel) {
+ }
+
+ private record MigratedFixture(ServerRoom room, EmbeddedChannel channel) {
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/room/RoomCommandQueueTest.java b/src/test/java/cn/rukkit/network/room/RoomCommandQueueTest.java
new file mode 100644
index 0000000..031112f
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/room/RoomCommandQueueTest.java
@@ -0,0 +1,83 @@
+package cn.rukkit.network.room;
+
+import cn.rukkit.network.command.GameCommand;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RoomCommandQueueTest {
+ @Test
+ void drainPreservesFifoOrder() {
+ RoomCommandQueue queue = new RoomCommandQueue();
+ GameCommand first = command(1);
+ GameCommand second = command(2);
+
+ queue.addLast(first);
+ queue.addLast(second);
+
+ List batch = queue.drain();
+
+ assertEquals(2, batch.size());
+ assertSame(first, batch.get(0));
+ assertSame(second, batch.get(1));
+ assertTrue(queue.isEmpty());
+ }
+
+ @Test
+ void prependRestoresFailedBatchBeforeNewCommands() {
+ RoomCommandQueue queue = new RoomCommandQueue();
+ GameCommand first = command(1);
+ GameCommand second = command(2);
+ GameCommand later = command(3);
+
+ queue.addLast(first);
+ queue.addLast(second);
+ List batch = queue.drain();
+ queue.addLast(later);
+ queue.prepend(batch);
+
+ List restored = queue.drain();
+ assertEquals(List.of(first, second, later), restored);
+ }
+
+ @Test
+ void concurrentProducersDoNotLoseCommands() throws Exception {
+ RoomCommandQueue queue = new RoomCommandQueue();
+ int producerCount = 4;
+ int commandsPerProducer = 250;
+ ExecutorService executor = Executors.newFixedThreadPool(producerCount);
+ try {
+ List> futures = new ArrayList<>();
+ for (int producer = 0; producer < producerCount; producer++) {
+ int producerId = producer;
+ futures.add(executor.submit(() -> {
+ for (int i = 0; i < commandsPerProducer; i++) {
+ queue.addLast(command(producerId * commandsPerProducer + i));
+ }
+ }));
+ }
+ for (Future> future : futures) {
+ future.get();
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertEquals(producerCount * commandsPerProducer, queue.size());
+ assertEquals(producerCount * commandsPerProducer, queue.drain().size());
+ }
+
+ private static GameCommand command(int marker) {
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {(byte) marker};
+ return command;
+ }
+}
From c4f95ac0a1c9617741a70c936a4b1f47c17025df Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Mon, 10 Aug 2026 13:58:30 +0800
Subject: [PATCH 7/9] feat(network): complete core room runtime and command
adapters
---
src/main/java/cn/rukkit/Rukkit.java | 97 +-
.../java/cn/rukkit/command/ChatCommand.java | 31 +
.../cn/rukkit/command/ChatCommandContext.java | 33 +
.../command/ChatCommandContextListener.java | 16 +
.../cn/rukkit/command/CommandManager.java | 63 ++
.../cn/rukkit/command/RoomCommandContext.java | 25 +
.../command/ServerChatCommandContext.java | 84 ++
.../java/cn/rukkit/config/RoundConfig.java | 16 +
.../java/cn/rukkit/config/RukkitConfig.java | 19 +
.../java/cn/rukkit/game/PlayerManager.java | 68 +-
.../cn/rukkit/game/map/CustomMapLoader.java | 24 +-
.../network/core/CoreRoomGameServer.java | 165 ++++
.../core/handler/ServerAddChatHandler.java | 81 ++
.../handler/ServerAddGameCommandHandler.java | 292 ++++++
.../handler/ServerChatCommandDispatcher.java | 18 +
.../core/handler/ServerConnectionHandler.java | 8 +-
.../core/handler/ServerDisconnectHandler.java | 40 +
.../ServerHeartbeatResponseHandler.java | 36 +
.../handler/ServerPacketHandlerManager.java | 36 +
.../core/handler/ServerPlayerInfoHandler.java | 200 ++--
.../handler/ServerPreRegisterHandler.java | 41 +
.../ServerQuestionResponseHandler.java | 45 +
.../core/handler/ServerRandyHandler.java | 40 +
.../ServerSyncChecksumResponseHandler.java | 74 ++
.../core/handler/ServerSyncHandler.java | 57 ++
.../network/room/RoomConnectionManager.java | 118 ++-
.../room/ServerGlobalConnectionManager.java | 52 +-
.../cn/rukkit/network/room/ServerRoom.java | 339 +++++--
.../network/room/ServerRoomConnection.java | 6 +-
.../network/room/ServerRoomManager.java | 24 +-
.../java/cn/rukkit/plugin/PluginManager.java | 2 +-
.../cn/rukkit/plugin/internal/BasePlugin.java | 55 +-
.../plugin/internal/CoreCommandPlugin.java | 852 ++++++++++++++++++
.../internal/CoreTestCommandPlugin.java | 147 +++
.../plugin/internal/MapCommandSupport.java | 71 ++
.../plugin/internal/ServerCommandPlugin.java | 87 +-
.../CommandManagerServerConnectionTest.java | 181 ++++
.../cn/rukkit/config/RukkitConfigTest.java | 40 +
.../core/CoreRoomGameServerBehaviorTest.java | 542 +++++++++++
...rverAddGameCommandHandlerBehaviorTest.java | 340 +++++++
.../ServerLowRiskHandlerBehaviorTest.java | 200 ++++
.../ServerPlayerInfoHandlerBehaviorTest.java | 188 +++-
.../ServerSyncHandlerBehaviorTest.java | 220 +++++
.../UniversalPacketCompatibilityTest.java | 79 ++
.../network/room/ServerRoomBehaviorTest.java | 100 +-
.../room/ServerRoomManagerBehaviorTest.java | 19 +
46 files changed, 4985 insertions(+), 286 deletions(-)
create mode 100644 src/main/java/cn/rukkit/command/ChatCommandContext.java
create mode 100644 src/main/java/cn/rukkit/command/ChatCommandContextListener.java
create mode 100644 src/main/java/cn/rukkit/command/RoomCommandContext.java
create mode 100644 src/main/java/cn/rukkit/command/ServerChatCommandContext.java
create mode 100644 src/main/java/cn/rukkit/network/core/CoreRoomGameServer.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerAddChatHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerChatCommandDispatcher.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerDisconnectHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerHeartbeatResponseHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerPreRegisterHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerQuestionResponseHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerRandyHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerSyncChecksumResponseHandler.java
create mode 100644 src/main/java/cn/rukkit/network/core/handler/ServerSyncHandler.java
create mode 100644 src/main/java/cn/rukkit/plugin/internal/CoreCommandPlugin.java
create mode 100644 src/main/java/cn/rukkit/plugin/internal/CoreTestCommandPlugin.java
create mode 100644 src/main/java/cn/rukkit/plugin/internal/MapCommandSupport.java
create mode 100644 src/test/java/cn/rukkit/command/CommandManagerServerConnectionTest.java
create mode 100644 src/test/java/cn/rukkit/config/RukkitConfigTest.java
create mode 100644 src/test/java/cn/rukkit/network/core/CoreRoomGameServerBehaviorTest.java
create mode 100644 src/test/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandlerBehaviorTest.java
create mode 100644 src/test/java/cn/rukkit/network/core/handler/ServerLowRiskHandlerBehaviorTest.java
create mode 100644 src/test/java/cn/rukkit/network/core/handler/ServerSyncHandlerBehaviorTest.java
diff --git a/src/main/java/cn/rukkit/Rukkit.java b/src/main/java/cn/rukkit/Rukkit.java
index ac165c8..04722a5 100644
--- a/src/main/java/cn/rukkit/Rukkit.java
+++ b/src/main/java/cn/rukkit/Rukkit.java
@@ -14,6 +14,11 @@
import cn.rukkit.game.NetworkPlayer;
import cn.rukkit.game.SaveData;
import cn.rukkit.network.*;
+import cn.rukkit.network.core.CoreRoomGameServer;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
import java.io.*;
import cn.rukkit.network.packet.handler.PacketHandlerManager;
@@ -57,22 +62,44 @@ public class Rukkit {
private static RoomManager roomManager;
private static SaveData defaultSave;
private static PacketHandlerManager packetHandlerManager;
+ private static CoreRoomGameServer coreServer;
+ private static ServerGlobalConnectionManager coreConnectionManager;
+ private static ServerRoomManager coreRoomManager;
+ private static ServerPacketHandlerManager corePacketHandlerManager;
public static void shutdown(String message) {
// TODO: Implement this method
log.info("Server will shutdown...");
log.info("Disconnect current players...");
- getGlobalConnectionManager().broadcastGlobalServerMessage("Server is stopped!");
+ if (isCoreNetworkEnabled() && coreConnectionManager != null) {
+ coreConnectionManager.broadcastGlobalServerMessage("Server is stopped!");
+ } else if (connectionManager != null) {
+ connectionManager.broadcastGlobalServerMessage("Server is stopped!");
+ }
log.info("Disabling all plugins...");
- pluginManager.disableAllPlugins();
+ if (pluginManager != null) {
+ pluginManager.disableAllPlugins();
+ }
log.info("Saving player data...");
- for (RoomConnection connection: getGlobalConnectionManager().getConnections()) {
- connection.player.savePlayerData();
+ if (isCoreNetworkEnabled() && coreConnectionManager != null) {
+ for (ServerRoomConnection connection : coreConnectionManager.getConnections()) {
+ connection.player.savePlayerData();
+ }
+ } else if (connectionManager != null) {
+ for (RoomConnection connection: connectionManager.getConnections()) {
+ connection.player.savePlayerData();
+ }
}
log.info("Stop ThreadManager...");
- getThreadManager().shutdown();
+ if (threadManager != null) {
+ threadManager.shutdown();
+ }
log.info("Shutdown server...");
- getGameServer().stopServer();
+ if (isCoreNetworkEnabled() && coreServer != null) {
+ coreServer.stopServer();
+ } else if (server != null) {
+ server.stopServer();
+ }
log.info("Stop terminal...");
RukkitLauncher.isTerminalRunning = false;
// RukkitLauncher.terminalThread.interrupt();
@@ -109,6 +136,11 @@ public static boolean isStarted() {
return isStarted;
}
+ /** Returns whether the migrated network runtime has been selected. */
+ public static boolean isCoreNetworkEnabled() {
+ return config != null && config.isCoreNetworkEnabled();
+ }
+
/**
* Get a rukkit config.
* {@link RukkitConfig}
@@ -145,6 +177,10 @@ public static RoomGameServer getGameServer() {
return server;
}
+ public static CoreRoomGameServer getCoreGameServer() {
+ return coreServer;
+ }
+
public static PluginManager getPluginManager() {
return pluginManager;
}
@@ -226,11 +262,23 @@ public static final ModManager getModManager() {
public static RoomManager getRoomManager() {
return roomManager;
}
+
+ public static ServerRoomManager getCoreRoomManager() {
+ return coreRoomManager;
+ }
+
+ public static ServerGlobalConnectionManager getCoreGlobalConnectionManager() {
+ return coreConnectionManager;
+ }
public static PacketHandlerManager getPacketHandlerManager() {
return packetHandlerManager;
}
+ public static ServerPacketHandlerManager getCorePacketHandlerManager() {
+ return corePacketHandlerManager;
+ }
+
public static void loadDefaultSave() throws IOException {
InputStream in = Rukkit.class.getClassLoader().getResourceAsStream("defaultSave");
byte[] data = new byte[in.available()];
@@ -299,6 +347,43 @@ public static final void startServer() throws IOException, InterruptedException
modManager.loadAllModsInDir();
log.info("init::CommandManager");
commandManager = new CommandManager();
+ if (isCoreNetworkEnabled()) {
+ log.info("init::CoreRoomManager");
+ coreRoomManager = new ServerRoomManager(round, config.maxRoom);
+ log.info("init::CoreConnectionManager");
+ coreConnectionManager = new ServerGlobalConnectionManager(coreRoomManager);
+ log.info("init::CorePacketHandlerManager");
+ corePacketHandlerManager = new ServerPacketHandlerManager();
+ corePacketHandlerManager.registerInternalHandler(
+ coreRoomManager,
+ coreConnectionManager,
+ (connection, command) -> commandManager.executeChatCommand(
+ connection, coreConnectionManager, command));
+ log.info("init::CoreRoomGameServer");
+ coreServer = new CoreRoomGameServer(
+ config.serverPort, corePacketHandlerManager, coreConnectionManager);
+
+ /* Core startup uses adapters that only depend on the migrated room model.
+ * Legacy plugins remain on the legacy startup path below. */
+ log.info("init::PluginManager (core-compatible plugins only)");
+ pluginManager = new PluginManager();
+ pluginManager.loadPlugin(new BasePlugin());
+ pluginManager.loadPlugin(new CoreCommandPlugin());
+ pluginManager.loadPlugin(new CoreTestCommandPlugin());
+ pluginManager.loadPlugin(new ServerCommandPlugin());
+ pluginManager.enableAllPlugins();
+
+ log.info("start::core game server on port:" + config.serverPort);
+ threadManager.submit(() -> {
+ try {
+ coreServer.action(time);
+ } catch (InterruptedException e) {
+ log.error("A error occurred:", e);
+ Thread.currentThread().interrupt();
+ }
+ });
+ return;
+ }
log.info("init::PacketHandlerManager");
packetHandlerManager = new PacketHandlerManager();
packetHandlerManager.registerInternalHandler();
diff --git a/src/main/java/cn/rukkit/command/ChatCommand.java b/src/main/java/cn/rukkit/command/ChatCommand.java
index 5dc4698..753eb91 100644
--- a/src/main/java/cn/rukkit/command/ChatCommand.java
+++ b/src/main/java/cn/rukkit/command/ChatCommand.java
@@ -18,6 +18,7 @@ public class ChatCommand
public boolean adminRequired = false;
private boolean isEnabled = false;
private ChatCommandListener chatListener;
+ private ChatCommandContextListener contextListener;
private RukkitPlugin fromPlugin;
@@ -38,6 +39,28 @@ public ChatCommand(String msg, String helpMessage,int args, ChatCommandListener
this.adminRequired = adminRequired;
}
+ /**
+ * Creates a command owned by the migrated network stack.
+ *
+ * This is a factory instead of an overloaded constructor so existing
+ * lambda calls targeting {@link ChatCommandListener} remain source
+ * compatible.
+ */
+ public static ChatCommand contextCommand(String msg, String helpMessage, int args,
+ ChatCommandContextListener contextListener, RukkitPlugin fromPlugin) {
+ return contextCommand(msg, helpMessage, args, contextListener, fromPlugin, false);
+ }
+
+ /** Creates a migrated command with an explicit administrator requirement. */
+ public static ChatCommand contextCommand(String msg, String helpMessage, int args,
+ ChatCommandContextListener contextListener, RukkitPlugin fromPlugin,
+ boolean adminRequired) {
+ ChatCommand command = new ChatCommand(msg, helpMessage, args,
+ (ChatCommandListener) null, fromPlugin, adminRequired);
+ command.setContextListener(contextListener);
+ return command;
+ }
+
public RukkitPlugin getFromPlugin() {
return fromPlugin;
}
@@ -57,4 +80,12 @@ public void setListener(ChatCommandListener listener) {
public ChatCommandListener getListener() {
return chatListener;
}
+
+ public void setContextListener(ChatCommandContextListener listener) {
+ this.contextListener = listener;
+ }
+
+ public ChatCommandContextListener getContextListener() {
+ return contextListener;
+ }
}
diff --git a/src/main/java/cn/rukkit/command/ChatCommandContext.java b/src/main/java/cn/rukkit/command/ChatCommandContext.java
new file mode 100644
index 0000000..f08195d
--- /dev/null
+++ b/src/main/java/cn/rukkit/command/ChatCommandContext.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.command;
+
+import cn.rukkit.game.NetworkPlayer;
+
+/**
+ * Application-level capabilities exposed to a chat command.
+ *
+ * The context deliberately hides the concrete network connection and
+ * packet implementation so the same command semantics can serve both
+ * network stacks during migration.
+ */
+public interface ChatCommandContext {
+ NetworkPlayer player();
+
+ int connectionCount();
+
+ int activeThreadCount();
+
+ int threadPoolCount();
+
+ void sendServerMessage(String message);
+
+ void broadcastCommandEcho(String command);
+}
diff --git a/src/main/java/cn/rukkit/command/ChatCommandContextListener.java b/src/main/java/cn/rukkit/command/ChatCommandContextListener.java
new file mode 100644
index 0000000..ef3dae6
--- /dev/null
+++ b/src/main/java/cn/rukkit/command/ChatCommandContextListener.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.command;
+
+/** Listener contract for commands running on the connection-neutral context. */
+@FunctionalInterface
+public interface ChatCommandContextListener {
+ boolean onSend(ChatCommandContext context, String[] args);
+}
diff --git a/src/main/java/cn/rukkit/command/CommandManager.java b/src/main/java/cn/rukkit/command/CommandManager.java
index 0f6c37d..9cb01ba 100644
--- a/src/main/java/cn/rukkit/command/CommandManager.java
+++ b/src/main/java/cn/rukkit/command/CommandManager.java
@@ -19,6 +19,8 @@
import org.jline.reader.impl.completer.StringsCompleter;
import org.slf4j.*;
import cn.rukkit.network.*;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoomConnection;
import cn.rukkit.*;
import cn.rukkit.network.packet.*;
import java.io.*;
@@ -33,6 +35,10 @@ public class CommandManager
public void registerCommand(ChatCommand cmd) {
log.debug(String.format("Registering Command '%s' from plugin '%s'...",cmd.cmd,cmd.getFromPlugin().config.name));
+ if (cmd.getContextListener() == null
+ && cmd.getListener() instanceof ChatCommandContextListener contextListener) {
+ cmd.setContextListener(contextListener);
+ }
if (fetchCommand(cmd.cmd) != null) {
log.warn(String.format("Command '%s' had already registered.",cmd.cmd));
} else {
@@ -49,6 +55,20 @@ public void registerServerCommand(ServerCommand cmd) {
serverCmdString.add(cmd.cmd);
}
}
+
+ /**
+ * Normalizes the optional prefix accepted by the {@code qc} command.
+ *
+ * The nested command may be written as {@code command},
+ * {@code -command}, or {@code .command}. Only one prefix is removed.
+ */
+ public static String normalizeNestedCommand(String command) {
+ if (command == null || command.isEmpty()) {
+ return command;
+ }
+ char prefix = command.charAt(0);
+ return prefix == '-' || prefix == '.' ? command.substring(1) : command;
+ }
public void executeChatCommand(RoomConnection connection, String cmd) {
String[] cmds = cmd.split("\\s+", 2);
@@ -80,6 +100,49 @@ public void executeChatCommand(RoomConnection connection, String cmd) {
}
}
+ /**
+ * Execute a command for the migrated connection stack.
+ *
+ * The legacy {@link ChatCommandListener} is intentionally not invoked
+ * here because it requires a legacy {@link RoomConnection}. Commands are
+ * migrated by assigning a {@link ChatCommandContextListener} to the
+ * command; until then the command is reported as not migrated.
+ */
+ public void executeChatCommand(ServerRoomConnection connection, String cmd) {
+ executeChatCommand(connection, null, cmd);
+ }
+
+ /** Execute a command with migrated global runtime services. */
+ public void executeChatCommand(ServerRoomConnection connection,
+ ServerGlobalConnectionManager globalConnectionManager,
+ String cmd) {
+ ChatCommandContext context = new ServerChatCommandContext(connection, globalConnectionManager);
+ String[] cmds = cmd.split("\\s+", 2);
+ ChatCommand cmdObj = fetchCommand(cmds[0]);
+ if (cmdObj == null) {
+ context.sendServerMessage(LangUtil.getString("chat.invalidCommand"));
+ return;
+ }
+ if (cmdObj.adminRequired && !context.player().isAdmin) {
+ context.sendServerMessage(LangUtil.getString("chat.privDenied"));
+ return;
+ }
+
+ ChatCommandContextListener listener = cmdObj.getContextListener();
+ if (listener == null) {
+ log.warn("Command '{}' has no migrated context listener", cmds[0]);
+ return;
+ }
+
+ log.trace("cmd is:{}", cmds[0]);
+ String[] args = cmds.length > 1 && cmdObj.args > 0
+ ? cmds[1].split(" ", cmdObj.args)
+ : new String[0];
+ if (listener.onSend(context, args)) {
+ context.broadcastCommandEcho(cmd);
+ }
+ }
+
public void executeServerCommand(String cmd) {
String[] cmds = cmd.split("\\s+", 2);
ServerCommand cmdObj = fetchServerCommand(cmds[0]);
diff --git a/src/main/java/cn/rukkit/command/RoomCommandContext.java b/src/main/java/cn/rukkit/command/RoomCommandContext.java
new file mode 100644
index 0000000..257b592
--- /dev/null
+++ b/src/main/java/cn/rukkit/command/RoomCommandContext.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.command;
+
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+
+/**
+ * Optional room capability for commands that need the current core room.
+ *
+ * Keeping this capability separate from {@link ChatCommandContext} prevents
+ * room-specific state from becoming part of every command's base contract.
+ */
+public interface RoomCommandContext extends ChatCommandContext {
+ ServerRoom room();
+
+ ServerRoomConnection connection();
+}
diff --git a/src/main/java/cn/rukkit/command/ServerChatCommandContext.java b/src/main/java/cn/rukkit/command/ServerChatCommandContext.java
new file mode 100644
index 0000000..1895dba
--- /dev/null
+++ b/src/main/java/cn/rukkit/command/ServerChatCommandContext.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.command;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoomConnection;
+
+import java.io.IOException;
+import java.util.Objects;
+
+/** Context adapter for commands executed by the migrated network stack. */
+public final class ServerChatCommandContext implements RoomCommandContext {
+ private final ServerRoomConnection connection;
+ private final ServerGlobalConnectionManager globalConnectionManager;
+
+ public ServerChatCommandContext(ServerRoomConnection connection) {
+ this(connection, null);
+ }
+
+ public ServerChatCommandContext(ServerRoomConnection connection,
+ ServerGlobalConnectionManager globalConnectionManager) {
+ this.connection = Objects.requireNonNull(connection, "connection must not be null");
+ this.globalConnectionManager = globalConnectionManager;
+ }
+
+ @Override
+ public cn.rukkit.game.NetworkPlayer player() {
+ return connection.player;
+ }
+
+ @Override
+ public cn.rukkit.network.room.ServerRoom room() {
+ return connection.currectRoom;
+ }
+
+ @Override
+ public ServerRoomConnection connection() {
+ return connection;
+ }
+
+ @Override
+ public int connectionCount() {
+ if (globalConnectionManager != null) {
+ return globalConnectionManager.size();
+ }
+ return connection.currectRoom.connectionManager.size();
+ }
+
+ @Override
+ public int activeThreadCount() {
+ return Rukkit.getThreadManager() == null
+ ? 0 : Rukkit.getThreadManager().getActiveThreadCount();
+ }
+
+ @Override
+ public int threadPoolCount() {
+ return Rukkit.getConfig() == null ? 0 : Rukkit.getConfig().threadPoolCount;
+ }
+
+ @Override
+ public void sendServerMessage(String message) {
+ connection.sendServerMessage(message);
+ }
+
+ @Override
+ public void broadcastCommandEcho(String command) {
+ try {
+ connection.currectRoom.connectionManager.broadcast(
+ UniversalPacket.chat(connection.player.name,
+ "-" + command,
+ connection.player.playerIndex));
+ } catch (IOException ignored) {
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/config/RoundConfig.java b/src/main/java/cn/rukkit/config/RoundConfig.java
index 49db8a9..d8c077d 100644
--- a/src/main/java/cn/rukkit/config/RoundConfig.java
+++ b/src/main/java/cn/rukkit/config/RoundConfig.java
@@ -25,4 +25,20 @@ public class RoundConfig extends BaseConfig
public RoundConfig() {
this.configName = "round.yml";
}
+
+ /** Creates an independent room configuration from the loaded defaults. */
+ public RoundConfig(RoundConfig source) {
+ this();
+ if (source == null) {
+ return;
+ }
+ this.mapName = source.mapName;
+ this.mapType = source.mapType;
+ this.income = source.income;
+ this.credits = source.credits;
+ this.disableNuke = source.disableNuke;
+ this.sharedControl = source.sharedControl;
+ this.fogType = source.fogType;
+ this.startingUnits = source.startingUnits;
+ }
}
diff --git a/src/main/java/cn/rukkit/config/RukkitConfig.java b/src/main/java/cn/rukkit/config/RukkitConfig.java
index 3cf7032..14d27dd 100644
--- a/src/main/java/cn/rukkit/config/RukkitConfig.java
+++ b/src/main/java/cn/rukkit/config/RukkitConfig.java
@@ -13,6 +13,8 @@
public class RukkitConfig extends BaseConfig
{
+ /** Network runtime selection. Legacy remains the compatibility default. */
+ public NetworkConfig network = new NetworkConfig();
public String serverUser = "RUKKIT";
public String welcomeMsg = "Welcome to Rukkit server, {playerName}!";
public String serverMotd = "My Rukkit server";
@@ -45,6 +47,23 @@ public class RukkitConfig extends BaseConfig
public boolean useCommandQuere = false;
public boolean checksumSync = false;
+
+ public String getNetworkMode() {
+ if (network == null || network.mode == null) {
+ return "legacy";
+ }
+ String mode = network.mode.trim().toLowerCase(Locale.ROOT);
+ return "core".equals(mode) ? "core" : "legacy";
+ }
+
+ public boolean isCoreNetworkEnabled() {
+ return "core".equals(getNetworkMode());
+ }
+
+ public static class NetworkConfig {
+ /** Supported values are legacy and core. Unknown values fall back to legacy. */
+ public String mode = "legacy";
+ }
public RukkitConfig() {
this.configName = "rukkit.yml";
diff --git a/src/main/java/cn/rukkit/game/PlayerManager.java b/src/main/java/cn/rukkit/game/PlayerManager.java
index 4e6bce2..500f741 100644
--- a/src/main/java/cn/rukkit/game/PlayerManager.java
+++ b/src/main/java/cn/rukkit/game/PlayerManager.java
@@ -43,7 +43,7 @@ public PlayerManager(ServerRoom room, int maxPlayer) {
/**
* Add a player into Array.
*/
- public int add(NetworkPlayer p) {
+ public synchronized int add(NetworkPlayer p) {
for(int i=0;i= players.length)) {
return;
}
+ if (index < 0 || index >= players.length) {
+ return;
+ }
// if(Rukkit.getConfig().nonStopMode) {
// players[index] = new NetworkPlayer();
// return;
@@ -104,14 +115,14 @@ public void remove(int index){
/**
* Get player by index.
*/
- public NetworkPlayer get(int index){
- if (index > players.length - 1) return null;
+ public synchronized NetworkPlayer get(int index){
+ if (index < 0 || index > players.length - 1) return null;
return players[index];
}
- public NetworkPlayer getPlayerByUUID(String uuid) {
+ public synchronized NetworkPlayer getPlayerByUUID(String uuid) {
for (NetworkPlayer p: players) {
- if (p.uuid.equals(uuid)) {
+ if (p.uuid != null && p.uuid.equals(uuid)) {
return p;
}
}
@@ -121,7 +132,7 @@ public NetworkPlayer getPlayerByUUID(String uuid) {
/**
* get a player index.
*/
- public int getIndex(NetworkPlayer p){
+ public synchronized int getIndex(NetworkPlayer p){
for(int i=0;i= players.length) {
+ return;
+ }
players[index] = p;
}
/**
* reset array.useful for reseting a game.
*/
- public void reset(){
+ public synchronized void reset(){
players = new NetworkPlayer[max];
for (int i = 0;i < players.length;i++) {
NetworkPlayer emptyPlayer = new NetworkPlayer();
@@ -197,7 +227,7 @@ public void reset(){
}
}
- public void clearDisconnectedPlayers() {
+ public synchronized void clearDisconnectedPlayers() {
for (int i=0;i getMapList(){
File folder = new File(MAP_FOLDER);
ArrayList list = new ArrayList();
- for(String f: folder.list()){
- String[] n = f.split("\\.");
- if(n[n.length - 1].equals("tmx")){
+ String[] files = folder.list();
+ if (files == null) {
+ return list;
+ }
+ for(String f: files){
+ if(f.endsWith(".tmx")){
list.add(f);
}
}
@@ -53,14 +56,13 @@ public static ArrayList getMapList(){
public static ArrayList getMapNameList(){
File folder = new File(MAP_FOLDER);
ArrayList list = new ArrayList();
- for(String f: folder.list()){
- String[] n = f.split("\\.");
- if(n[n.length - 1].equals("tmx")){
- StringBuffer sbf = new StringBuffer();
- for(int i = 0;i < n.length -1;i++){
- sbf.append(n[i]);
- }
- list.add(sbf.toString());
+ String[] files = folder.list();
+ if (files == null) {
+ return list;
+ }
+ for(String f: files){
+ if(f.endsWith(".tmx")){
+ list.add(f.substring(0, f.length() - ".tmx".length()));
}
}
return list;
diff --git a/src/main/java/cn/rukkit/network/core/CoreRoomGameServer.java b/src/main/java/cn/rukkit/network/core/CoreRoomGameServer.java
new file mode 100644
index 0000000..904a79c
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/CoreRoomGameServer.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.core.packet.PacketDecoder;
+import cn.rukkit.network.core.packet.PacketEncoder;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.logging.LogLevel;
+import io.netty.handler.logging.LoggingHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.InetSocketAddress;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Netty entry point for the migrated packet and room runtime.
+ *
+ * The server deliberately mirrors the legacy server's blocking
+ * {@link #action(long)} contract so it can be submitted to the existing
+ * {@code ThreadManager}. The runtime owns only core-layer objects; legacy
+ * packet handlers are never installed in this pipeline.
+ */
+public final class CoreRoomGameServer {
+ private static final Logger LOG = LoggerFactory.getLogger("CoreGameServer");
+
+ private final int port;
+ private final ServerPacketHandlerManager packetHandlerManager;
+ private final ServerGlobalConnectionManager globalConnectionManager;
+ private final CountDownLatch bindCompleted = new CountDownLatch(1);
+
+ private volatile NioEventLoopGroup bossGroup;
+ private volatile NioEventLoopGroup workerGroup;
+ private volatile ChannelFuture serverFuture;
+ private volatile Throwable startFailure;
+
+ public CoreRoomGameServer(int port,
+ ServerPacketHandlerManager packetHandlerManager,
+ ServerGlobalConnectionManager globalConnectionManager) {
+ if (port < 0 || port > 65535) {
+ throw new IllegalArgumentException("port must be between 0 and 65535");
+ }
+ this.port = port;
+ this.packetHandlerManager = Objects.requireNonNull(
+ packetHandlerManager, "packetHandlerManager must not be null");
+ this.globalConnectionManager = Objects.requireNonNull(
+ globalConnectionManager, "globalConnectionManager must not be null");
+ }
+
+ /**
+ * Starts listening and blocks until the listening channel is closed.
+ */
+ public void action(final long time) throws InterruptedException {
+ bossGroup = new NioEventLoopGroup();
+ workerGroup = new NioEventLoopGroup();
+ try {
+ ServerBootstrap bootstrap = new ServerBootstrap();
+ bootstrap.group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .option(ChannelOption.SO_BACKLOG, 128)
+ .childOption(ChannelOption.SO_KEEPALIVE, true)
+ .handler(new LoggingHandler(LOG.getName(), LogLevel.DEBUG))
+ .childHandler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("connection", new ServerConnectionHandler(
+ packetHandlerManager,
+ globalConnectionManager::discard));
+ }
+ });
+
+ serverFuture = bootstrap.bind(port).sync();
+ bindCompleted.countDown();
+ LOG.info("Done! ({}ms), core network listening on {}",
+ System.currentTimeMillis() - time, getBoundPort());
+ Rukkit.setStarted(true);
+ serverFuture.channel().closeFuture().sync();
+ } catch (InterruptedException e) {
+ startFailure = e;
+ bindCompleted.countDown();
+ throw e;
+ } catch (Throwable e) {
+ startFailure = e;
+ bindCompleted.countDown();
+ LOG.error("Unable to start core game server", e);
+ } finally {
+ Rukkit.setStarted(false);
+ shutdownGroups();
+ }
+ }
+
+ /**
+ * Waits until bind succeeds or fails. This is useful for startup
+ * coordination and loopback smoke tests without exposing Netty internals.
+ */
+ public boolean awaitStarted(long timeout, TimeUnit unit) throws InterruptedException {
+ if (!bindCompleted.await(timeout, unit)) {
+ return false;
+ }
+ return startFailure == null && getBoundPort() >= 0;
+ }
+
+ public boolean isRunning() {
+ ChannelFuture future = serverFuture;
+ return future != null && future.channel().isOpen();
+ }
+
+ /** Returns the actual bound port, or -1 before a successful bind. */
+ public int getBoundPort() {
+ ChannelFuture future = serverFuture;
+ if (future == null || future.channel().localAddress() == null) {
+ return -1;
+ }
+ if (future.channel().localAddress() instanceof InetSocketAddress address) {
+ return address.getPort();
+ }
+ return -1;
+ }
+
+ public Throwable getStartFailure() {
+ return startFailure;
+ }
+
+ public void stopServer() {
+ ChannelFuture future = serverFuture;
+ if (future != null) {
+ future.channel().close();
+ }
+ shutdownGroups();
+ }
+
+ private synchronized void shutdownGroups() {
+ if (workerGroup != null) {
+ workerGroup.shutdownGracefully();
+ workerGroup = null;
+ }
+ if (bossGroup != null) {
+ bossGroup.shutdownGracefully();
+ bossGroup = null;
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerAddChatHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerAddChatHandler.java
new file mode 100644
index 0000000..07238ff
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerAddChatHandler.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.event.player.PlayerChatEvent;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.room.ServerRoomConnection;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Master-compatible ordinary chat handler for the core packet stack.
+ *
+ * Chat command dispatch is intentionally kept as a separate migration
+ * step because the legacy command API accepts {@code RoomConnection}, while
+ * this handler owns a {@code ServerRoomConnection}.
+ */
+public class ServerAddChatHandler extends ServerPacketHandler {
+ private final ServerChatCommandDispatcher commandDispatcher;
+
+ /** Creates a handler with command dispatch left for the compatibility layer. */
+ public ServerAddChatHandler() {
+ this.commandDispatcher = null;
+ }
+
+ public ServerAddChatHandler(ServerChatCommandDispatcher commandDispatcher) {
+ this.commandDispatcher = Objects.requireNonNull(
+ commandDispatcher, "commandDispatcher must not be null");
+ }
+
+ @Override
+ public int getType() {
+ return PacketType.ADD_CHAT;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ ServerRoomConnection connection = context.connection();
+ if (connection == null || connection.player == null) {
+ return;
+ }
+
+ GameInputStream input = new GameInputStream(packet);
+ String chatMessage = input.readString();
+ if (chatMessage.startsWith(".")
+ || chatMessage.startsWith("-")
+ || chatMessage.startsWith("_")) {
+ if (commandDispatcher == null) {
+ getLogger().debug("Chat command dispatch is not migrated yet: {}", chatMessage);
+ } else {
+ commandDispatcher.dispatch(connection, chatMessage.substring(1));
+ }
+ return;
+ }
+
+ if (PlayerChatEvent.getListenerList().callListeners(
+ new PlayerChatEvent(connection.player, chatMessage))) {
+ connection.currectRoom.connectionManager.broadcast(
+ UniversalPacket.chat(connection.player.name,
+ chatMessage,
+ connection.player.playerIndex));
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandler.java
new file mode 100644
index 0000000..ca98deb
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandler.java
@@ -0,0 +1,292 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.event.Event;
+import cn.rukkit.event.ListenerList;
+import cn.rukkit.event.action.BuildEvent;
+import cn.rukkit.event.action.MoveEvent;
+import cn.rukkit.event.action.PingEvent;
+import cn.rukkit.event.action.TaskEvent;
+import cn.rukkit.game.GameActions;
+import cn.rukkit.game.UnitType;
+import cn.rukkit.game.unit.InternalUnit;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.io.GzipDecoder;
+import cn.rukkit.network.io.GzipEncoder;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.util.MathUtil;
+
+import java.io.DataInputStream;
+import java.util.List;
+
+/**
+ * Master-compatible game command decoder for the core packet stack.
+ *
+ * The command is decoded and re-encoded in the same shape as the master
+ * handler so action events are raised before the command is broadcast to the
+ * room. The concrete connection type is the only runtime-specific part.
+ */
+public class ServerAddGameCommandHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.ADD_GAMECOMMAND;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ ServerRoomConnection connection = context.connection();
+ if (connection == null || connection.player == null) {
+ getLogger().warn("Ignore PACKET_ADD_GAMECOMMAND because connection is not ready.");
+ return;
+ }
+
+ GameInputStream input = new GameInputStream(packet);
+ GameCommand command = new GameCommand();
+ command.arr = input.getDecodeBytes();
+
+ GameInputStream commandInput = new GameInputStream(command.arr);
+ GameOutputStream output = new GameOutputStream();
+ Event actionEvent = null;
+
+ byte index = commandInput.readByte();
+ output.writeByte(index);
+ getLogger().debug("-- Command Recording --");
+ getLogger().debug("teamIndex={}", index);
+
+ if (commandInput.readBoolean()) {
+ getLogger().debug("-- BasicGameAction --");
+ output.writeBoolean(true);
+ GameActions action = commandInput.readEnum(GameActions.class);
+ output.writeEnum(action);
+ getLogger().debug("Action={}", action);
+
+ int unitIndex = commandInput.readInt();
+ output.writeInt(unitIndex);
+ getLogger().debug("BuildUnit:{}", unitIndex);
+ String targetUnit = "";
+ if (unitIndex == -2) {
+ targetUnit = commandInput.readString();
+ output.writeString(targetUnit);
+ getLogger().debug("Custom={}", targetUnit);
+ }
+ if (unitIndex != -1 && unitIndex != -2) {
+ targetUnit = InternalUnit.units[unitIndex];
+ }
+
+ float x = commandInput.readFloat();
+ output.writeFloat(x);
+ float y = commandInput.readFloat();
+ output.writeFloat(y);
+ long targetUnitId = commandInput.readLong();
+ output.writeLong(targetUnitId);
+ getLogger().debug("TargetUnitID={}", targetUnitId);
+
+ byte byte1 = commandInput.readByte();
+ float float1 = commandInput.readFloat();
+ float float2 = commandInput.readFloat();
+ boolean bool1 = commandInput.readBoolean();
+ boolean bool2 = commandInput.readBoolean();
+ boolean bool3 = commandInput.readBoolean();
+ output.writeByte(byte1);
+ output.writeFloat(float1);
+ output.writeFloat(float2);
+ output.writeBoolean(bool1);
+ output.writeBoolean(bool2);
+ output.writeBoolean(bool3);
+
+ if (commandInput.readBoolean()) {
+ output.writeBoolean(true);
+ String actionId = commandInput.readString();
+ output.writeString(actionId);
+ getLogger().debug("SPECIALACTIONID={}", actionId);
+ } else {
+ output.writeBoolean(false);
+ }
+ switch (action) {
+ case BUILD:
+ actionEvent = new BuildEvent(connection.player, x, y,
+ targetUnitId, targetUnit);
+ break;
+ case MOVE:
+ actionEvent = new MoveEvent(connection.player, x, y, targetUnitId);
+ break;
+ default:
+ break;
+ }
+ getLogger().debug("-- End BasicGameAction --");
+ } else {
+ output.writeBoolean(false);
+ }
+
+ boolean bool4 = commandInput.readBoolean();
+ boolean isCancel = commandInput.readBoolean();
+ output.writeBoolean(bool4);
+ output.writeBoolean(isCancel);
+
+ int int1 = commandInput.readInt();
+ int int2 = commandInput.readInt();
+ output.writeInt(int1);
+ output.writeInt(int2);
+
+ if (commandInput.readBoolean()) {
+ output.writeBoolean(true);
+ output.writeFloat(commandInput.readFloat());
+ output.writeFloat(commandInput.readFloat());
+ } else {
+ output.writeBoolean(false);
+ }
+
+ boolean bool6 = commandInput.readBoolean();
+ int unitCount = commandInput.readInt();
+ output.writeBoolean(bool6);
+ output.writeInt(unitCount);
+ for (int i = 0; i < unitCount; i++) {
+ output.writeLong(commandInput.readLong());
+ }
+
+ // The field is the pre-command player. The original server binds it
+ // to the connection that submitted the command instead of trusting
+ // the player index supplied by the client.
+ boolean hasCommandPlayer = commandInput.readBoolean();
+ if (hasCommandPlayer) {
+ commandInput.readByte();
+ }
+ output.writeBoolean(true);
+ output.writeByte(connection.player.playerIndex);
+
+ float pingX = 0;
+ float pingY = 0;
+ if (commandInput.readBoolean()) {
+ output.writeBoolean(true);
+ pingX = commandInput.readFloat();
+ pingY = commandInput.readFloat();
+ output.writeFloat(pingX);
+ output.writeFloat(pingY);
+ } else {
+ output.writeBoolean(false);
+ }
+
+ long unitId = commandInput.readLong();
+ output.writeLong(unitId);
+
+ String buildUnit = commandInput.readString();
+ if (!buildUnit.equals("-1")) {
+ if (buildUnit.startsWith("c_6_")) {
+ actionEvent = new PingEvent(connection.player, pingX, pingY, buildUnit);
+ } else {
+ actionEvent = new TaskEvent(connection.player, buildUnit, unitId, isCancel);
+ }
+ }
+ output.writeString(buildUnit);
+
+ boolean bool7 = commandInput.readBoolean();
+ output.writeBoolean(bool7);
+
+ commandInput.readShort();
+ output.writeShort(connection.currectRoom.playerManager.getSharedControlMask());
+
+ // A client cannot submit a system action. Consume the payload so the
+ // decoder remains aligned, then clear the flag like the original
+ // NetworkEngine.processCommandPacket().
+ if (commandInput.readBoolean()) {
+ commandInput.readByte();
+ commandInput.readFloat();
+ commandInput.readFloat();
+ commandInput.readInt();
+ }
+ output.writeBoolean(false);
+
+ int movementUnitCount = commandInput.readInt();
+ output.writeInt(movementUnitCount);
+ for (int i = 0; i < movementUnitCount; i++) {
+ output.writeLong(commandInput.readLong());
+ output.writeFloat(commandInput.readFloat());
+ output.writeFloat(commandInput.readFloat());
+ output.writeFloat(commandInput.readFloat());
+ output.writeFloat(commandInput.readFloat());
+ output.writeInt(commandInput.readInt());
+ output.writeEnum(commandInput.readEnum(UnitType.class));
+
+ if (commandInput.readBoolean()) {
+ output.writeBoolean(true);
+ if (commandInput.readBoolean()) {
+ output.writeBoolean(true);
+ GzipEncoder pathOutput = output.getEncodeStream("p", true);
+ byte[] bytes = commandInput.getDecodeBytes();
+ GzipDecoder decoder = new GzipDecoder(bytes);
+ DataInputStream pathInput = decoder.stream;
+
+ int pathCount = pathInput.readInt();
+ pathOutput.stream.writeInt(pathCount);
+ if (pathCount > 0) {
+ short unitX = pathInput.readShort();
+ short unitY = pathInput.readShort();
+ pathOutput.stream.writeShort(unitX);
+ pathOutput.stream.writeShort(unitY);
+ for (int pathIndex = 1; pathIndex < pathCount; pathIndex++) {
+ int length = pathInput.readByte();
+ pathOutput.stream.writeByte(length);
+ if (length < 128) {
+ int deltaX = (length & 3) - 1;
+ int deltaY = ((length & 12) >> 2) - 1;
+ if (MathUtil.abs(deltaX) > 1 || MathUtil.abs(deltaY) > 1) {
+ getLogger().warn("Bad unit path.");
+ }
+ unitX = (short) (unitX + deltaX);
+ unitY = (short) (unitY + deltaY);
+ } else {
+ unitX = pathInput.readShort();
+ unitY = pathInput.readShort();
+ pathOutput.stream.writeShort(unitX);
+ pathOutput.stream.writeShort(unitY);
+ }
+ }
+ }
+ output.flushEncodeData(pathOutput);
+ } else {
+ output.writeBoolean(false);
+ }
+ } else {
+ output.writeBoolean(false);
+ }
+ }
+
+ boolean finalFlag = commandInput.readBoolean();
+ output.writeBoolean(finalFlag);
+ getLogger().debug("-- Command recording end --");
+
+ Packet gamePacket = output.createPacket(PacketType.TICK);
+ command.arr = gamePacket.bytes;
+ if (actionEvent != null) {
+ ListenerList listenerList = (ListenerList) actionEvent.getClass()
+ .getMethod("getListenerList").invoke(null);
+ if (listenerList.callListeners(actionEvent)) {
+ connection.player.markCommandActivity();
+ connection.sendGameCommand(command);
+ } else {
+ getLogger().debug("Event {} cancelled!", actionEvent);
+ }
+ } else {
+ connection.player.markCommandActivity();
+ connection.sendGameCommand(command);
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerChatCommandDispatcher.java b/src/main/java/cn/rukkit/network/core/handler/ServerChatCommandDispatcher.java
new file mode 100644
index 0000000..882641b
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerChatCommandDispatcher.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.room.ServerRoomConnection;
+
+/** Dispatches a chat command for the migrated connection model. */
+@FunctionalInterface
+public interface ServerChatCommandDispatcher {
+ void dispatch(ServerRoomConnection connection, String command) throws Exception;
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
index 51b892c..0a5a912 100644
--- a/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerConnectionHandler.java
@@ -85,8 +85,12 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
super.channelInactive(ctx);
setState(ConnectionState.DISCONNECTED);
if (conn != null) {
- PlayerLeftEvent.getListenerList().callListeners(
- new PlayerLeftEvent(conn.player, disconnectReason));
+ boolean currentConnection = conn.player == null
+ || conn.player.getServerConnection() == conn;
+ if (currentConnection) {
+ PlayerLeftEvent.getListenerList().callListeners(
+ new PlayerLeftEvent(conn.player, disconnectReason));
+ }
if (currentRoom != null && currentRoom.connectionManager != null) {
currentRoom.connectionManager.discard(conn);
}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerDisconnectHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerDisconnectHandler.java
new file mode 100644
index 0000000..f767505
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerDisconnectHandler.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+
+import java.util.List;
+
+/** Master-compatible client disconnect handler for the core packet stack. */
+public class ServerDisconnectHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.DISCONNECT;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.PRE_REGISTERED,
+ ConnectionState.IN_ROOM,
+ ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ GameInputStream input = new GameInputStream(packet);
+ context.handler().setDisconnectReason(input.readString());
+ context.transitionTo(ConnectionState.DISCONNECTED);
+ context.ctx().disconnect();
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerHeartbeatResponseHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerHeartbeatResponseHandler.java
new file mode 100644
index 0000000..0558928
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerHeartbeatResponseHandler.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+
+import java.util.List;
+
+/** Master-compatible heartbeat response handler for the core packet stack. */
+public class ServerHeartbeatResponseHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.HEART_BEAT_RESPONSE;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) {
+ if (context.connection() != null) {
+ context.connection().pong();
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
index 5142d4c..e742de1 100644
--- a/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPacketHandlerManager.java
@@ -9,6 +9,8 @@
package cn.rukkit.network.core.handler;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoomManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +34,40 @@ public void unregister(ServerPacketHandler handler) {
unregister(handler.getType());
}
+ /**
+ * Register the core handlers that have already been migrated from master.
+ *
+ * This registry is intentionally separate from the legacy packet
+ * handler manager while both network runtimes coexist.
+ */
+ public void registerInternalHandler(ServerRoomManager roomManager,
+ ServerGlobalConnectionManager globalConnectionManager) {
+ registerInternalHandler(roomManager, globalConnectionManager, null);
+ }
+
+ /**
+ * Register migrated handlers with an optional new-stack chat command
+ * dispatcher. The legacy command manager remains outside this registry.
+ */
+ public void registerInternalHandler(ServerRoomManager roomManager,
+ ServerGlobalConnectionManager globalConnectionManager,
+ ServerChatCommandDispatcher commandDispatcher) {
+ register(new ServerPreRegisterHandler());
+ register(new ServerPlayerInfoHandler(roomManager, globalConnectionManager));
+ register(new ServerHeartbeatResponseHandler());
+ register(new ServerDisconnectHandler());
+ register(new ServerQuestionResponseHandler());
+ register(new ServerRandyHandler());
+ register(new ServerAddGameCommandHandler());
+ register(new ServerSyncHandler());
+ register(new ServerSyncChecksumResponseHandler());
+ if (commandDispatcher == null) {
+ register(new ServerAddChatHandler());
+ } else {
+ register(new ServerAddChatHandler(commandDispatcher));
+ }
+ }
+
public boolean dispatch(ServerPacketContext context,
cn.rukkit.network.core.packet.Packet packet) throws Exception {
ServerPacketHandler handler = handlers.get(packet.type);
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
index 4406492..9b9ac14 100644
--- a/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandler.java
@@ -65,106 +65,118 @@ public void handle(ServerPacketContext context, Packet packet) throws Exception
getLogger().debug("Got Player(package={}, version={}, name={}, uuid={}, coreUnit={})",
packageName, gameVersionCode, playerName, uuid, coreUnitCheck);
- ServerRoom room = roomManager.getAvailableRoom();
- NetworkPlayer targetPlayer = globalConnectionManager.getAllPlayerByUUID(uuid);
- ServerRoom currentRoom;
- if (targetPlayer != null && Rukkit.getConfig().syncEnabled) {
- currentRoom = targetPlayer.getServerRoom();
- getLogger().info("Found offline room {}", currentRoom);
- } else {
- currentRoom = room;
- }
- context.handler().setCurrentRoom(currentRoom);
-
- if (currentRoom == null) {
- context.ctx().writeAndFlush(UniversalPacket.kick(LangUtil.getString("rukkit.gameFull")));
- return;
- }
-
- if (!currentRoom.isGaming() && targetPlayer != null) {
- getLogger().info("Dup player {} (UUID={}) joined!", playerName, uuid);
- if (Rukkit.getConfig().isDebug) {
- getLogger().info("You are in the debug mode, allowing this situation!");
- targetPlayer = null;
+ synchronized (roomManager) {
+ ServerRoom room = roomManager.getAvailableRoom();
+ NetworkPlayer targetPlayer = globalConnectionManager.getAllPlayerByUUID(uuid);
+ ServerRoom currentRoom;
+ if (targetPlayer != null && Rukkit.getConfig().syncEnabled
+ && targetPlayer.getServerRoom() != null) {
+ currentRoom = targetPlayer.getServerRoom();
+ getLogger().info("Found offline room {}", currentRoom);
} else {
- context.ctx().writeAndFlush(UniversalPacket.kick("You are already in server!"));
- return;
+ currentRoom = room;
}
- }
-
- context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config));
-
- ServerRoomConnection connection = new ServerRoomConnection(context.handler(), currentRoom);
- if (targetPlayer != null && Rukkit.getConfig().syncEnabled) {
- connection.player = targetPlayer;
- connection.player.name = playerName;
- connection.player.bindServerConnection(connection);
- } else {
- NetworkPlayer player = new NetworkPlayer(connection);
- player.name = playerName;
- player.uuid = uuid;
- connection.player = player;
- }
- context.bindConnection(connection);
-
- if (currentRoom.connectionManager.size() <= 0) {
- connection.sendServerMessage(LangUtil.getString("rukkit.playerGotAdmin"));
- connection.player.isAdmin = true;
- context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config, true));
- } else {
- context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config));
- }
+ context.handler().setCurrentRoom(currentRoom);
- if (currentRoom.isGaming()) {
- if (Rukkit.getConfig().syncEnabled) {
- getLogger().info("Start Syncing!");
- context.handler().stopTimeout();
- connection.player.updateServerInfo();
- currentRoom.connectionManager.set(connection, connection.player.playerIndex);
- connection.startTeamTask();
- connection.updateTeamList(false);
- connection.startPingTask();
- connection.handler.ctx.writeAndFlush(UniversalPacket.startGame());
- currentRoom.syncGame();
- connection.player.isDisconnected = false;
- PlayerReconnectEvent.getListenerList().callListeners(
- new PlayerReconnectEvent(connection.player));
- } else {
- context.ctx().writeAndFlush(UniversalPacket.kick(LangUtil.getString("rukkit.gameStarted")));
+ if (currentRoom == null) {
+ context.ctx().writeAndFlush(UniversalPacket.kick(LangUtil.getString("rukkit.gameFull")));
return;
}
- }
-
- globalConnectionManager.add(connection);
- if (targetPlayer == null) {
- currentRoom.connectionManager.add(connection);
- }
- try {
- connection.player.loadPlayerData();
- } catch (Exception e) {
- getLogger().warn("Player {} data load failed!", playerName, e);
- }
- String simpleUuid = uuid.length() > 7 ? uuid.substring(0, 7) : uuid;
- connection.sendServerMessage(LangUtil.getFormatString("rukkit.room", currentRoom.roomId));
- connection.sendServerMessage(Rukkit.getConfig().welcomeMsg
- .replace("{playerName}", playerName)
- .replace("{simpleUUID}", simpleUuid)
- .replace("{packageName}", packageName)
- .replace("{versionCode}", String.valueOf(gameVersionCode)));
-
- if (targetPlayer == null) {
- connection.startPingTask();
- connection.startTeamTask();
- connection.updateTeamList(false);
- context.handler().stopTimeout();
- PlayerJoinEvent.getListenerList().callListeners(new PlayerJoinEvent(connection.player));
- }
-
- if (currentRoom.isGaming()) {
- context.transitionTo(ConnectionState.IN_GAME);
- } else {
- context.transitionTo(ConnectionState.IN_ROOM);
+ synchronized (currentRoom) {
+ boolean reconnecting = targetPlayer != null && Rukkit.getConfig().syncEnabled;
+ if (!currentRoom.isGaming() && targetPlayer != null) {
+ getLogger().info("Dup player {} (UUID={}) joined!", playerName, uuid);
+ if (Rukkit.getConfig().isDebug) {
+ getLogger().info("You are in the debug mode, allowing this situation!");
+ targetPlayer = null;
+ reconnecting = false;
+ } else {
+ context.ctx().writeAndFlush(UniversalPacket.kick("You are already in server!"));
+ return;
+ }
+ }
+
+ if (currentRoom.isGaming() && !reconnecting) {
+ context.ctx().writeAndFlush(
+ UniversalPacket.kick(LangUtil.getString("rukkit.gameStarted")));
+ return;
+ }
+
+ ServerRoomConnection connection = new ServerRoomConnection(context.handler(), currentRoom);
+ if (reconnecting) {
+ connection.player = targetPlayer;
+ connection.player.name = playerName;
+ connection.player.bindServerConnection(connection);
+ } else {
+ NetworkPlayer player = new NetworkPlayer(connection);
+ player.name = playerName;
+ player.uuid = uuid;
+ connection.player = player;
+ }
+ context.bindConnection(connection);
+
+ boolean registered = reconnecting
+ ? currentRoom.connectionManager.set(connection, connection.player.playerIndex)
+ : currentRoom.connectionManager.add(connection);
+ if (!registered) {
+ context.bindConnection(null);
+ context.ctx().writeAndFlush(
+ UniversalPacket.kick(LangUtil.getString("rukkit.gameFull")));
+ return;
+ }
+ globalConnectionManager.add(connection);
+
+ context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config));
+ if (currentRoom.connectionManager.size() <= 1) {
+ connection.sendServerMessage(LangUtil.getString("rukkit.playerGotAdmin"));
+ connection.player.isAdmin = true;
+ context.ctx().writeAndFlush(UniversalPacket.serverInfo(currentRoom.config, true));
+ }
+
+ if (currentRoom.isGaming()) {
+ getLogger().info("Start Syncing!");
+ context.handler().stopTimeout();
+ connection.player.updateServerInfo();
+ connection.startTeamTask();
+ connection.updateTeamList(false);
+ connection.startPingTask();
+ connection.handler.ctx.writeAndFlush(
+ UniversalPacket.startGame(connection.currectRoom.config));
+ currentRoom.syncGame();
+ connection.player.isDisconnected = false;
+ PlayerReconnectEvent.getListenerList().callListeners(
+ new PlayerReconnectEvent(connection.player));
+ }
+
+ try {
+ connection.player.loadPlayerData();
+ } catch (Exception e) {
+ getLogger().warn("Player {} data load failed!", playerName, e);
+ }
+ String simpleUuid = uuid.length() > 7 ? uuid.substring(0, 7) : uuid;
+ connection.sendServerMessage(LangUtil.getFormatString("rukkit.room", currentRoom.roomId));
+ connection.sendServerMessage(Rukkit.getConfig().welcomeMsg
+ .replace("{playerName}", playerName)
+ .replace("{simpleUUID}", simpleUuid)
+ .replace("{packageName}", packageName)
+ .replace("{versionCode}", String.valueOf(gameVersionCode)));
+
+ if (!reconnecting) {
+ connection.startPingTask();
+ connection.startTeamTask();
+ connection.updateTeamList(false);
+ context.handler().stopTimeout();
+ PlayerJoinEvent.getListenerList().callListeners(
+ new PlayerJoinEvent(connection.player));
+ }
+
+ if (currentRoom.isGaming()) {
+ context.transitionTo(ConnectionState.IN_GAME);
+ } else {
+ context.transitionTo(ConnectionState.IN_ROOM);
+ }
+ }
}
}
}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerPreRegisterHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerPreRegisterHandler.java
new file mode 100644
index 0000000..01c8c4f
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerPreRegisterHandler.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.util.LangUtil;
+
+import java.util.List;
+
+/** Master-compatible pre-registration handler for the core packet stack. */
+public class ServerPreRegisterHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.PREREGISTER_CONNECTION;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.CONNECTED);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ getLogger().debug("Received PACKET_PREREGISTER_CONNECTION");
+ getLogger().debug("New connection established:{}", context.ctx().channel().remoteAddress());
+ context.ctx().write(UniversalPacket.preRegister());
+ context.ctx().writeAndFlush(
+ UniversalPacket.chat("SERVER", LangUtil.getString("rukkit.playerRegister"), -1));
+ context.transitionTo(ConnectionState.PRE_REGISTERED);
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerQuestionResponseHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerQuestionResponseHandler.java
new file mode 100644
index 0000000..b64e563
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerQuestionResponseHandler.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.event.server.ServerQuestionRespondEvent;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+
+import java.util.List;
+
+/** Master-compatible question response handler for the core packet stack. */
+public class ServerQuestionResponseHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.QUESTION_RESPONSE;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ if (context.connection() == null || context.connection().player == null) {
+ return;
+ }
+ GameInputStream input = new GameInputStream(packet);
+ input.readByte();
+ int questionId = input.readInt();
+ String response = input.readString();
+ ServerQuestionRespondEvent.getListenerList().callListeners(
+ new ServerQuestionRespondEvent(
+ context.connection().player, questionId, response));
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerRandyHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerRandyHandler.java
new file mode 100644
index 0000000..795dada
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerRandyHandler.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.room.ServerRoomConnection;
+
+import java.util.List;
+
+/** Master-compatible READY/Randy notification handler for the core stack. */
+public class ServerRandyHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.READY;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) {
+ ServerRoomConnection connection = context.connection();
+ if (connection == null || connection.player == null || connection.currectRoom == null) {
+ return;
+ }
+ connection.currectRoom.connectionManager.broadcastServerMessage(
+ String.format("Player '%s' is randy.", connection.player.name));
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerSyncChecksumResponseHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerSyncChecksumResponseHandler.java
new file mode 100644
index 0000000..67b8c17
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerSyncChecksumResponseHandler.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+
+import java.io.DataInputStream;
+import java.util.List;
+
+/** Master-compatible checksum response handler for the core packet stack. */
+public class ServerSyncChecksumResponseHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.SYNC_CHECKSUM_RESPONSE;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ if (context.connection() == null || context.connection().player == null) {
+ return;
+ }
+
+ GameInputStream input = new GameInputStream(packet);
+ input.readByte();
+ int serverTick = input.readInt();
+ int clientTick = input.readInt();
+ getLogger().info("[{}] Server tick: {}, Client tick: {}",
+ context.connection().player.name, serverTick, clientTick);
+ context.connection().lastSyncTick = clientTick;
+
+ if (input.readBoolean()) {
+ getLogger().info("Player {} send checksum!", context.connection().player.name);
+ input.readLong();
+ input.readLong();
+ DataInputStream checksumStream = input.getUnDecodeStream();
+ checksumStream.readInt();
+ int checksumCount = checksumStream.readInt();
+ getLogger().debug("Total checksum: {}", checksumCount);
+ for (int i = 0; i < checksumCount; i++) {
+ checksumStream.readLong();
+ long clientCheckData = checksumStream.readLong();
+ getLogger().trace("{}: client={}",
+ context.connection().player.checkList.get(i).getDescription(),
+ clientCheckData);
+ context.connection().player.checkList.get(i).setCheckData(clientCheckData);
+ }
+
+ context.connection().currectRoom.checkSumReceived.incrementAndGet();
+ context.connection().checkSumSent = true;
+ synchronized (context.connection().currectRoom.checkSumReceived) {
+ context.connection().currectRoom.checkSumReceived.notifyAll();
+ }
+ } else {
+ getLogger().info("Player {} did'n send checksum!We can sent back again!",
+ context.connection().player.name);
+ context.connection().doChecksum();
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/handler/ServerSyncHandler.java b/src/main/java/cn/rukkit/network/core/handler/ServerSyncHandler.java
new file mode 100644
index 0000000..4add18a
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/handler/ServerSyncHandler.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.game.SaveData;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+
+import java.util.List;
+
+/** Master-compatible save synchronization handler for the core packet stack. */
+public class ServerSyncHandler extends ServerPacketHandler {
+ @Override
+ public int getType() {
+ return PacketType.SYNC;
+ }
+
+ @Override
+ public List getAllowedStates() {
+ return List.of(ConnectionState.IN_GAME);
+ }
+
+ @Override
+ public void handle(ServerPacketContext context, Packet packet) throws Exception {
+ if (context.connection() == null) {
+ return;
+ }
+
+ GameInputStream input = new GameInputStream(packet);
+ input.readByte();
+ int frame = input.readInt();
+ int time = input.readInt() / 15;
+ getLogger().trace("sync frame={} payload: {}, {}, {}, {}",
+ frame,
+ input.readFloat(),
+ input.readFloat(),
+ input.readBoolean(),
+ input.readBoolean());
+
+ byte[] save = input.getBlockRaw("gameSave");
+ if (save.length > 20) {
+ SaveData data = new SaveData();
+ data.arr = save;
+ data.time = time;
+ context.connection().save = data;
+ }
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java b/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
index 5b0b84d..b397574 100644
--- a/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
+++ b/src/main/java/cn/rukkit/network/room/RoomConnectionManager.java
@@ -12,6 +12,7 @@
import cn.rukkit.game.NetworkPlayer;
import cn.rukkit.game.PlayerManager;
import cn.rukkit.game.SaveData;
+import cn.rukkit.network.ConnectionState;
import cn.rukkit.network.core.packet.Packet;
import cn.rukkit.network.core.packet.UniversalPacket;
import io.netty.channel.group.ChannelGroup;
@@ -23,13 +24,13 @@
import org.slf4j.LoggerFactory;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
/** Master-compatible connection manager for {@link ServerRoom}. */
public class RoomConnectionManager {
private final ServerRoom room;
- public volatile List connections = new ArrayList<>();
+ public final List connections = new CopyOnWriteArrayList<>();
private final ChannelGroup channelGroup;
private final PlayerManager playerManager;
private final Logger log;
@@ -42,16 +43,57 @@ public RoomConnectionManager(ServerRoom room) {
"ChannelGroups" + room.roomId, GlobalEventExecutor.INSTANCE);
}
- public void add(ServerRoomConnection connection) {
+ /**
+ * Retains the master API surface while the player-list representation is
+ * still owned by {@link PlayerManager}.
+ */
+ public void getPlayerAsList() {
+ // Kept for source compatibility with master callers.
+ }
+
+ public synchronized boolean add(ServerRoomConnection connection) {
+ if (connection == null || connection.player == null || connection.handler == null
+ || connection.handler.ctx == null) {
+ return false;
+ }
+ if (connections.contains(connection)) {
+ return true;
+ }
+ if (!playerManager.addWithTeam(connection.player)) {
+ return false;
+ }
connections.add(connection);
- playerManager.addWithTeam(connection.player);
channelGroup.add(connection.handler.ctx.channel());
+ return true;
}
- public void set(ServerRoomConnection connection, int index) {
- connections.add(connection);
+ public synchronized boolean set(ServerRoomConnection connection, int index) {
+ if (connection == null || connection.player == null || connection.handler == null
+ || connection.handler.ctx == null) {
+ return false;
+ }
+ NetworkPlayer currentPlayer = playerManager.get(index);
+ if (currentPlayer == null || (!currentPlayer.isEmpty && currentPlayer != connection.player)) {
+ return false;
+ }
+ connection.player.playerIndex = index;
playerManager.set(index, connection.player);
+ for (ServerRoomConnection existing : connections) {
+ if (existing != connection && existing.player == connection.player) {
+ connections.remove(existing);
+ existing.stopPingTask();
+ existing.stopTeamTask();
+ if (existing.handler != null && existing.handler.ctx != null) {
+ channelGroup.remove(existing.handler.ctx.channel());
+ }
+ }
+ }
+ if (connections.contains(connection)) {
+ return true;
+ }
+ connections.add(connection);
channelGroup.add(connection.handler.ctx.channel());
+ return true;
}
public ChannelGroupFuture broadcast(Packet packet) {
@@ -66,24 +108,52 @@ public ChannelGroup flush() {
return channelGroup.flush();
}
- public boolean discard(ServerRoomConnection connection) {
- connection.handler.ctx.disconnect();
- connections.remove(connection);
- playerManager.remove(connection.player);
- if (connection.player.isAdmin && playerManager.getPlayerCount() > 0) {
- for (NetworkPlayer player : playerManager.getPlayerArray()) {
- if (!player.isEmpty && player.getServerConnection() != null) {
- player.isAdmin = true;
- try {
- player.getServerConnection().sendPacket(
- UniversalPacket.serverInfo(room.config, true));
- } catch (IOException ignored) {
- }
- break;
- }
+ public synchronized boolean discard(ServerRoomConnection connection) {
+ if (connection == null) {
+ return false;
+ }
+ boolean wasRegistered = connections.remove(connection);
+ boolean currentConnection = connection.player != null
+ && connection.player.getServerConnection() == connection;
+ if (wasRegistered && currentConnection) {
+ boolean wasAdmin = connection.player.isAdmin;
+ playerManager.remove(connection.player);
+ if (wasAdmin) {
+ connection.player.isAdmin = false;
+ transferAdminToLiveConnection();
}
}
- return channelGroup.remove(connection.handler.ctx.channel());
+ if (connection.handler != null && connection.handler.ctx != null) {
+ connection.handler.ctx.disconnect();
+ return channelGroup.remove(connection.handler.ctx.channel());
+ }
+ return false;
+ }
+
+ private void transferAdminToLiveConnection() {
+ for (ServerRoomConnection candidate : connections) {
+ if (!isLive(candidate)) {
+ continue;
+ }
+ candidate.player.isAdmin = true;
+ try {
+ candidate.sendPacket(UniversalPacket.serverInfo(room.config, true));
+ } catch (IOException ignored) {
+ }
+ return;
+ }
+ }
+
+ private boolean isLive(ServerRoomConnection connection) {
+ return connection != null
+ && connection.player != null
+ && !connection.player.isEmpty
+ && !connection.player.isDisconnected
+ && connection.player.getServerConnection() == connection
+ && connection.handler != null
+ && connection.handler.getState() != ConnectionState.DISCONNECTED
+ && connection.handler.ctx != null
+ && connection.handler.ctx.channel().isOpen();
}
public ChannelGroupFuture disconnect() {
@@ -95,11 +165,11 @@ public ChannelGroupFuture disconnect(ChannelMatcher matcher) {
}
public boolean contains(ServerRoomConnection connection) {
- return channelGroup.contains(connection.handler.ctx.channel());
+ return connections.contains(connection);
}
public int size() {
- return channelGroup.size();
+ return connections.size();
}
public List getConnections() {
diff --git a/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java b/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
index 3f73163..56a87c2 100644
--- a/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
+++ b/src/main/java/cn/rukkit/network/room/ServerGlobalConnectionManager.java
@@ -23,10 +23,11 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
/** Global connection registry for the migrated room runtime. */
public class ServerGlobalConnectionManager {
- private final List connections = new ArrayList<>();
+ private final List connections = new CopyOnWriteArrayList<>();
private final ChannelGroup channelGroup;
private final ServerRoomManager roomManager;
private final Logger log = LoggerFactory.getLogger(ServerGlobalConnectionManager.class);
@@ -36,7 +37,19 @@ public ServerGlobalConnectionManager(ServerRoomManager roomManager) {
this.channelGroup = new DefaultChannelGroup("ServerChannelGroups", GlobalEventExecutor.INSTANCE);
}
- public void add(ServerRoomConnection connection) {
+ public synchronized void add(ServerRoomConnection connection) {
+ if (connection == null || connection.handler == null || connection.handler.ctx == null
+ || connections.contains(connection)) {
+ return;
+ }
+ for (ServerRoomConnection existing : connections) {
+ if (existing != connection && existing.player == connection.player) {
+ connections.remove(existing);
+ if (existing.handler != null && existing.handler.ctx != null) {
+ channelGroup.remove(existing.handler.ctx.channel());
+ }
+ }
+ }
connections.add(connection);
channelGroup.add(connection.handler.ctx.channel());
}
@@ -53,10 +66,16 @@ public ChannelGroup flush() {
return channelGroup.flush();
}
- public boolean discard(ServerRoomConnection connection) {
- connection.handler.ctx.disconnect();
+ public synchronized boolean discard(ServerRoomConnection connection) {
+ if (connection == null) {
+ return false;
+ }
connections.remove(connection);
- return channelGroup.remove(connection.handler.ctx.channel());
+ if (connection.handler != null && connection.handler.ctx != null) {
+ connection.handler.ctx.disconnect();
+ return channelGroup.remove(connection.handler.ctx.channel());
+ }
+ return false;
}
public ChannelGroupFuture disconnect() {
@@ -68,11 +87,11 @@ public ChannelGroupFuture disconnect(ChannelMatcher matcher) {
}
public boolean contains(ServerRoomConnection connection) {
- return channelGroup.contains(connection.handler.ctx.channel());
+ return connections.contains(connection);
}
public int size() {
- return channelGroup.size();
+ return connections.size();
}
public List getConnections() {
@@ -81,7 +100,8 @@ public List getConnections() {
public NetworkPlayer getPlayerByName(String name) {
for (ServerRoomConnection connection : connections) {
- if (connection.player.name.equals(name)) {
+ if (connection.player != null && connection.player.name != null
+ && connection.player.name.equals(name)) {
return connection.player;
}
}
@@ -90,7 +110,8 @@ public NetworkPlayer getPlayerByName(String name) {
public NetworkPlayer getPlayerByUUID(String uuid) {
for (ServerRoomConnection connection : connections) {
- if (connection.player.uuid.equals(uuid)) {
+ if (connection.player != null && connection.player.uuid != null
+ && connection.player.uuid.equals(uuid)) {
return connection.player;
}
}
@@ -98,10 +119,15 @@ public NetworkPlayer getPlayerByUUID(String uuid) {
}
public NetworkPlayer getAllPlayerByUUID(String uuid) {
- for (ServerRoom room : roomManager.roomList) {
- NetworkPlayer player = room.playerManager.getPlayerByUUID(uuid);
- if (player != null && !player.isEmpty) {
- return player;
+ if (uuid == null) {
+ return null;
+ }
+ synchronized (roomManager) {
+ for (ServerRoom room : new ArrayList<>(roomManager.roomList)) {
+ NetworkPlayer player = room.playerManager.getPlayerByUUID(uuid);
+ if (player != null && !player.isEmpty) {
+ return player;
+ }
}
}
return null;
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoom.java b/src/main/java/cn/rukkit/network/room/ServerRoom.java
index 89e62cc..225cf77 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoom.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoom.java
@@ -23,6 +23,7 @@
import cn.rukkit.network.command.GameCommand;
import cn.rukkit.network.core.packet.Packet;
import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.NetworkTick;
import cn.rukkit.util.Vote;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -30,7 +31,7 @@
import java.io.IOException;
import java.text.MessageFormat;
import java.util.HashMap;
-import java.util.LinkedList;
+import java.util.List;
import java.util.Random;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicInteger;
@@ -43,14 +44,18 @@
*/
public class ServerRoom {
private static final Logger log = LoggerFactory.getLogger(ServerRoom.class);
+ private static final int SYNC_TIMEOUT_MILLIS = 5000;
+ private static final int SYNC_POLL_INTERVAL_MILLIS = 50;
public PlayerManager playerManager;
public RoomConnectionManager connectionManager;
- private LinkedList commandQuere = new LinkedList();
+ private final RoomCommandQueue commandQuere = new RoomCommandQueue();
+ private final Object commandDispatchLock = new Object();
public RoundConfig config;
- public int stepRate = 200;
- public int currentStep = 0;
+ /** Approximate network window in milliseconds; scheduling uses nanoseconds. */
+ public int stepRate = NetworkTick.WINDOW_PERIOD_MILLIS;
+ public volatile int currentStep = 0;
public int checkSumFrame = 0;
public final AtomicInteger checkSumReceived = new AtomicInteger();
public int syncCount = 0;
@@ -58,9 +63,13 @@ public class ServerRoom {
private volatile boolean checkRequested = false;
public SaveData lastNoStopSave;
- private boolean isGaming = false;
- private boolean isPaused = false;
+ private volatile boolean gameStarted = false;
+ private volatile boolean isPaused = false;
private ScheduledFuture> gameTaskFuture;
+ private ScheduledFuture> syncTaskFuture;
+ private long syncDeadline;
+ private ScheduledFuture> checkSumTaskFuture;
+ private long checkSumDeadline;
private SaveManager saveManager;
public Vote vote;
@@ -69,15 +78,19 @@ public class ServerRoom {
public String toString() {
return MessageFormat.format(
"NetworkRoom [id = {0}, isGaming = {1}, isPaused = {2}, currentStep = {3}, stepRate = {4}]",
- roomId, isGaming, isPaused, currentStep, stepRate);
+ roomId, isGaming(), isPaused, currentStep, stepRate);
}
public ServerRoom(int id) {
+ this(id, Rukkit.getRoundConfig());
+ }
+
+ public ServerRoom(int id, RoundConfig defaultConfig) {
roomId = id;
playerManager = new PlayerManager(this, Rukkit.getConfig().maxPlayer);
connectionManager = new RoomConnectionManager(this);
saveManager = new SaveManager(this);
- config = Rukkit.getRoundConfig();
+ config = new RoundConfig(defaultConfig);
vote = new Vote(this);
}
@@ -149,31 +162,49 @@ public void check(int recheck) {
@Override
public void run() {
- if (checkRequested) {
- synchronized (checkSumReceived) {
- while (true) {
- try {
- checkSumReceived.wait();
- if (checkSumReceived.get() >= connectionManager.size()) {
- break;
- }
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
+ synchronized (ServerRoom.this) {
+ if (!checkRequested) {
+ finishCheckSumTask();
+ return;
+ }
+
+ if (connectionManager == null) {
+ checkRequested = false;
+ finishCheckSumTask();
+ return;
+ }
+
+ int connectionCount = connectionManager.size();
+ if (connectionCount <= 0
+ || checkSumReceived.get() >= connectionCount
+ || System.currentTimeMillis() >= checkSumDeadline) {
+ if (checkSumReceived.get() < connectionCount) {
+ LoggerFactory.getLogger("CheckSum Task Room #" + roomId)
+ .warn("Checksum response timeout: {}/{}",
+ checkSumReceived.get(), connectionCount);
}
+ check(0);
+ checkRequested = false;
+ finishCheckSumTask();
}
- check(0);
- checkRequested = false;
}
}
}
+ private void finishCheckSumTask() {
+ ScheduledFuture> task = checkSumTaskFuture;
+ checkSumTaskFuture = null;
+ if (task != null) {
+ task.cancel(false);
+ }
+ }
+
public class GameTask implements Runnable {
@Override
public void run() {
RukkitConfig cfg = Rukkit.getConfig();
if (!isPaused) {
- currentStep += 10;
+ currentStep += NetworkTick.FRAMES_PER_WINDOW;
if (cfg.checksumSync && currentStep % 300 == 0) {
if (!checkRequested) {
checkSumReceived.set(0);
@@ -198,20 +229,7 @@ public void run() {
return;
}
- synchronized (commandQuere) {
- try {
- if (commandQuere.isEmpty() && !isPaused) {
- connectionManager.broadcast(UniversalPacket.emptyCommand(currentStep));
- } else {
- while (!commandQuere.isEmpty() && !isPaused) {
- GameCommand command = commandQuere.removeLast();
- connectionManager.broadcast(
- UniversalPacket.gameCommand(currentStep, command));
- }
- }
- } catch (IOException ignored) {
- }
- }
+ dispatchQueuedCommands();
}
}
@@ -220,7 +238,7 @@ public class NonStopGameTask implements Runnable {
public void run() {
RukkitConfig cfg = Rukkit.getConfig();
if (!isPaused) {
- currentStep += 10;
+ currentStep += NetworkTick.FRAMES_PER_WINDOW;
}
if (connectionManager.size() == 1 && !cfg.singlePlayerMode && !isPaused) {
connectionManager.broadcastServerMessage(
@@ -234,20 +252,7 @@ public void run() {
return;
}
- synchronized (commandQuere) {
- try {
- if (commandQuere.isEmpty() && !isPaused) {
- connectionManager.broadcast(UniversalPacket.emptyCommand(currentStep));
- } else {
- while (!commandQuere.isEmpty() && !isPaused) {
- GameCommand command = commandQuere.removeLast();
- connectionManager.broadcast(
- UniversalPacket.gameCommand(currentStep, command));
- }
- }
- } catch (IOException ignored) {
- }
- }
+ dispatchQueuedCommands();
}
}
@@ -255,33 +260,46 @@ public class SyncTask implements Runnable {
@Override
public void run() {
Logger syncLog = LoggerFactory.getLogger("SyncTask #" + roomId);
- connectionManager.clearAllSaveData();
- setPaused(true);
- try {
- connectionManager.broadcast(UniversalPacket.sendPullSave(ServerRoom.this));
- SaveData save;
- long time = System.currentTimeMillis();
- while (true) {
- save = connectionManager.getAvailableSave();
- if (save != null) {
+ synchronized (ServerRoom.this) {
+ if (syncTaskFuture == null || syncTaskFuture.isCancelled()) {
+ return;
+ }
+
+ if (connectionManager == null) {
+ finishSyncTask();
+ return;
+ }
+
+ SaveData save = connectionManager.getAvailableSave();
+ if (save != null) {
+ try {
saveManager.setLastSave(save);
saveManager.sendLastSaveToAll(false);
syncCount++;
setPaused(false);
- break;
- } else if (System.currentTimeMillis() - time > 5000) {
- syncLog.warn("Sync failed!");
- setPaused(false);
- break;
+ finishSyncTask();
+ } catch (IOException e) {
+ syncLog.warn("A exception occurred.", e);
+ finishSyncTask();
+ stopGame();
}
+ } else if (System.currentTimeMillis() >= syncDeadline) {
+ syncLog.warn("Sync failed!");
+ setPaused(false);
+ finishSyncTask();
}
- } catch (IOException e) {
- syncLog.warn("A exception occurred.", e);
- stopGame();
}
}
}
+ private void finishSyncTask() {
+ ScheduledFuture> task = syncTaskFuture;
+ syncTaskFuture = null;
+ if (task != null) {
+ task.cancel(false);
+ }
+ }
+
public boolean isPaused() {
return isPaused;
}
@@ -290,22 +308,98 @@ public void setPaused(boolean paused) {
isPaused = paused;
}
+ /**
+ * Sends one room tick containing all commands currently pending. The
+ * dispatch lock is separate from the queue lock so producers can enqueue
+ * while a packet is being encoded, while sync and normal ticks cannot
+ * overtake each other.
+ */
+ private void dispatchQueuedCommands() {
+ synchronized (commandDispatchLock) {
+ if (isPaused) {
+ return;
+ }
+
+ List commands = commandQuere.drain();
+ try {
+ if (commands.isEmpty()) {
+ connectionManager.broadcast(UniversalPacket.emptyCommand(currentStep));
+ } else {
+ connectionManager.broadcast(
+ UniversalPacket.gameCommands(currentStep, commands));
+ }
+ } catch (IOException e) {
+ // Do not silently lose commands if packet construction fails.
+ commandQuere.prepend(commands);
+ log.warn("Failed to build command tick for room {}", roomId, e);
+ }
+ }
+ }
+
+ /**
+ * Completes the command boundary before a resync save is requested. The
+ * room is already paused when this method is called, but commands that
+ * arrived before the pause must be sent before the save request.
+ */
+ private void flushQueuedCommandsForSync() throws IOException {
+ synchronized (commandDispatchLock) {
+ List commands = commandQuere.drain();
+ if (commands.isEmpty()) {
+ return;
+ }
+ try {
+ connectionManager.broadcast(
+ UniversalPacket.gameCommands(currentStep, commands));
+ } catch (IOException e) {
+ commandQuere.prepend(commands);
+ throw e;
+ }
+ }
+ }
+
public void stopGame() {
stopGame(false);
}
public void doChecksum() {
- checkRequested = true;
- for (ServerRoomConnection connection : connectionManager.connections) {
- connection.doChecksum();
+ synchronized (this) {
+ if (connectionManager == null || checkRequested) {
+ return;
+ }
+ checkRequested = true;
+ checkSumReceived.set(0);
+ checkSumDeadline = System.currentTimeMillis() + SYNC_TIMEOUT_MILLIS;
+ for (ServerRoomConnection connection : connectionManager.connections) {
+ connection.doChecksum();
+ }
+ checkSumTaskFuture = Rukkit.getThreadManager().schedule(
+ new CheckSumTask(), SYNC_POLL_INTERVAL_MILLIS, SYNC_POLL_INTERVAL_MILLIS);
}
- Rukkit.getThreadManager().submit(new CheckSumTask());
}
- public void stopGame(boolean returnToBattleroom) {
+ public synchronized void stopGame(boolean returnToBattleroom) {
+ setPaused(true);
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
+ gameStarted = false;
currentStep = 0;
checkSumFrame = 0;
syncCount = 0;
+ checkRequested = false;
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ if (player != null && !player.isEmpty) {
+ player.endGameActivityTracking();
+ }
+ }
+ if (checkSumTaskFuture != null) {
+ checkSumTaskFuture.cancel(true);
+ checkSumTaskFuture = null;
+ }
+ if (syncTaskFuture != null) {
+ syncTaskFuture.cancel(true);
+ syncTaskFuture = null;
+ }
if (returnToBattleroom) {
try {
playerManager.clearDisconnectedPlayers();
@@ -320,7 +414,6 @@ public void stopGame(boolean returnToBattleroom) {
if (gameTaskFuture != null) {
gameTaskFuture.cancel(true);
}
- isGaming = false;
RoomStopGameEvent.getListenerList().callListeners(new RoomStopGameEvent(this));
}
@@ -329,6 +422,20 @@ public void broadcast(Packet packet) {
}
public void discard() {
+ setPaused(true);
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
+ gameStarted = false;
+ checkRequested = false;
+ if (checkSumTaskFuture != null) {
+ checkSumTaskFuture.cancel(true);
+ checkSumTaskFuture = null;
+ }
+ if (syncTaskFuture != null) {
+ syncTaskFuture.cancel(true);
+ syncTaskFuture = null;
+ }
playerManager.reset();
connectionManager.disconnect();
connectionManager.clearAllSaveData();
@@ -337,22 +444,57 @@ public void discard() {
}
public boolean isGaming() {
- if (currentStep <= 0) {
- isGaming = false;
- } else {
- isGaming = true;
+ if (gameStarted) {
+ return true;
}
- return isGaming;
+ // Keep compatibility with callers that restore a running room by
+ // restoring its tick, while avoiding a start-up window where the
+ // first tick has not been emitted yet.
+ if (currentStep > 0) {
+ gameStarted = true;
+ return true;
+ }
+ return false;
}
- public void syncGame() {
- Rukkit.getThreadManager().submit(new SyncTask());
+ public synchronized void syncGame() {
+ if (connectionManager == null
+ || (syncTaskFuture != null && !syncTaskFuture.isDone())) {
+ return;
+ }
+ connectionManager.clearAllSaveData();
+ setPaused(true);
+ try {
+ flushQueuedCommandsForSync();
+ connectionManager.broadcast(UniversalPacket.sendPullSave(ServerRoom.this));
+ } catch (IOException e) {
+ setPaused(false);
+ stopGame();
+ return;
+ }
+ syncDeadline = System.currentTimeMillis() + SYNC_TIMEOUT_MILLIS;
+ syncTaskFuture = Rukkit.getThreadManager().schedule(
+ new SyncTask(), SYNC_POLL_INTERVAL_MILLIS, SYNC_POLL_INTERVAL_MILLIS);
}
- public void startGame() {
+ public synchronized void startGame() {
+ if (gameStarted || currentStep > 0) {
+ return;
+ }
try {
- connectionManager.broadcast(UniversalPacket.gameStart());
- if (Rukkit.getRoundConfig().sharedControl) {
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
+ setPaused(false);
+ Packet gameStartPacket = UniversalPacket.gameStart(config);
+ gameStarted = true;
+ connectionManager.broadcast(gameStartPacket);
+ for (NetworkPlayer player : playerManager.getPlayerArray()) {
+ if (player != null && !player.isEmpty) {
+ player.beginGameActivityTracking();
+ }
+ }
+ if (config.sharedControl) {
for (NetworkPlayer player : playerManager.getPlayerArray()) {
try {
player.isNull();
@@ -368,19 +510,26 @@ public void startGame() {
connection.updateTeamList();
connection.handler.setState(ConnectionState.IN_GAME);
}
- gameTaskFuture = Rukkit.getThreadManager().schedule(new GameTask(), stepRate, stepRate);
- isGaming = true;
+ gameTaskFuture = Rukkit.getThreadManager().scheduleAtFixedRate(
+ new GameTask(),
+ NetworkTick.WINDOW_PERIOD_NANOS,
+ NetworkTick.WINDOW_PERIOD_NANOS,
+ java.util.concurrent.TimeUnit.NANOSECONDS);
RoomStartGameEvent.getListenerList().callListeners(new RoomStartGameEvent(this));
} catch (IOException ignored) {
+ gameStarted = false;
}
}
public void changeMapWhileRunning(String mapName, int type) {
- Rukkit.getRoundConfig().mapName = mapName;
- Rukkit.getRoundConfig().mapType = type;
+ synchronized (commandDispatchLock) {
+ commandQuere.clear();
+ }
+ config.mapName = mapName;
+ config.mapType = type;
try {
- connectionManager.broadcast(UniversalPacket.gameStart());
- if (Rukkit.getRoundConfig().sharedControl) {
+ connectionManager.broadcast(UniversalPacket.gameStart(config));
+ if (config.sharedControl) {
for (NetworkPlayer player : playerManager.getPlayerArray()) {
try {
player.isNull();
@@ -412,8 +561,16 @@ public int getCurrentStep() {
public void addCommand(GameCommand command) {
if (Rukkit.getConfig().useCommandQuere) {
- commandQuere.addLast(command);
+ synchronized (commandDispatchLock) {
+ if (isPaused()) {
+ return;
+ }
+ commandQuere.addLast(command);
+ }
} else {
+ if (isPaused()) {
+ return;
+ }
try {
broadcast(UniversalPacket.gameCommand(currentStep, command));
} catch (IOException ignored) {
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
index abe9fd8..ea97846 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomConnection.java
@@ -38,7 +38,7 @@ public class ServerRoomConnection {
public int lastSyncTick = 0;
public boolean checkSumSent = false;
public int numberOfDesyncError = 0;
- public SaveData save;
+ public volatile SaveData save;
private ScheduledFuture> pingFuture;
private ScheduledFuture> teamFuture;
@@ -194,6 +194,10 @@ public void sendPacket(Packet packet) {
}
public void pong() {
+ if (player == null) {
+ return;
+ }
player.ping = (int) (System.currentTimeMillis() - pingTime);
+ player.recordHeartbeat();
}
}
diff --git a/src/main/java/cn/rukkit/network/room/ServerRoomManager.java b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
index 6968f47..42a2e16 100644
--- a/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
+++ b/src/main/java/cn/rukkit/network/room/ServerRoomManager.java
@@ -24,17 +24,21 @@
*/
public class ServerRoomManager {
public List roomList;
+ private final RoundConfig defaultConfig;
+ private final int maxRoom;
public ServerRoomManager(RoundConfig defaultConfig, int maxRoom) {
roomList = new ArrayList<>(maxRoom);
+ this.defaultConfig = new RoundConfig(defaultConfig);
+ this.maxRoom = maxRoom;
resetAllRooms();
}
- public void addConnection(ServerRoomConnection connection, int roomId) {
+ public synchronized void addConnection(ServerRoomConnection connection, int roomId) {
getRoom(roomId).connectionManager.add(connection);
}
- public void addConnection(ServerRoomConnection connection) {
+ public synchronized void addConnection(ServerRoomConnection connection) {
if (connection.currectRoom != null) {
connection.currectRoom.connectionManager.add(connection);
return;
@@ -53,11 +57,13 @@ public ServerRoom getRoom(int index) {
return roomList.get(index);
}
- public ServerRoom getAvailableRoom() {
+ public synchronized ServerRoom getAvailableRoom() {
for (ServerRoom room : roomList) {
- if (room.playerManager.getPlayerCount() < room.playerManager.getMaxPlayer()
- && !room.isGaming()) {
- return room;
+ synchronized (room) {
+ if (room.playerManager.getPlayerCount() < room.playerManager.getMaxPlayer()
+ && !room.isGaming()) {
+ return room;
+ }
}
}
return null;
@@ -71,7 +77,7 @@ public ServerRoom getAvailableRoom() {
* a room. The migrated registry performs the same broadcast/disconnect/
* discard sequence on a snapshot and then rebuilds the list.
*/
- public void resetAllRooms() {
+ public synchronized void resetAllRooms() {
for (ServerRoom room : new ArrayList<>(roomList)) {
if (room == null) {
continue;
@@ -85,8 +91,8 @@ public void resetAllRooms() {
}
}
roomList.clear();
- for (int id = 0; id < Rukkit.getConfig().maxRoom; id++) {
- roomList.add(new ServerRoom(id));
+ for (int id = 0; id < maxRoom; id++) {
+ roomList.add(new ServerRoom(id, defaultConfig));
}
}
}
diff --git a/src/main/java/cn/rukkit/plugin/PluginManager.java b/src/main/java/cn/rukkit/plugin/PluginManager.java
index 5e1213d..61d9a95 100644
--- a/src/main/java/cn/rukkit/plugin/PluginManager.java
+++ b/src/main/java/cn/rukkit/plugin/PluginManager.java
@@ -218,7 +218,7 @@ public void disableAllPlugins()
}
//启用所有插件
- void enableAllPlugins()
+ public void enableAllPlugins()
{
for (RukkitPlugin plugin: pluginMap.values())
{
diff --git a/src/main/java/cn/rukkit/plugin/internal/BasePlugin.java b/src/main/java/cn/rukkit/plugin/internal/BasePlugin.java
index 2087be6..79c2956 100644
--- a/src/main/java/cn/rukkit/plugin/internal/BasePlugin.java
+++ b/src/main/java/cn/rukkit/plugin/internal/BasePlugin.java
@@ -20,7 +20,8 @@
import cn.rukkit.event.player.PlayerJoinEvent;
import cn.rukkit.event.player.PlayerLeftEvent;
import cn.rukkit.event.player.PlayerReconnectEvent;
-import cn.rukkit.network.RoomConnection;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.NetworkRoom;
import cn.rukkit.plugin.PluginConfig;
import cn.rukkit.util.LangUtil;
import cn.rukkit.util.VersionUtil;
@@ -36,29 +37,61 @@ public class BasePlugin extends InternalRukkitPlugin implements EventListener {
@EventHandler
public void onPlayerJoinTip(PlayerJoinEvent event) {
- event.getPlayer().getRoom().connectionManager.broadcastServerMessage(MessageFormat.format(LangUtil.getString("rukkit.playerJoin"), event.getPlayer().name));
- LoggerFactory.getLogger("Room #" + event.getPlayer().getRoom().roomId).info("Player {} joined!", event.getPlayer().name);
+ NetworkPlayer player = event.getPlayer();
+ broadcastServerMessage(player,
+ MessageFormat.format(LangUtil.getString("rukkit.playerJoin"), player.name));
+ LoggerFactory.getLogger("Room #" + roomId(player)).info("Player {} joined!", player.name);
}
@EventHandler
public void onPlayerLeaveTip(PlayerLeftEvent event) {
- event.getPlayer().getRoom().connectionManager.broadcastServerMessage(MessageFormat.format(LangUtil.getString("rukkit.playerLeft"), event.getPlayer().name, event.getReason()));
- if (event.getPlayer().getRoom().isGaming()) {
- event.getPlayer().sendTeamMessage(LangUtil.getString("rukkit.playerSharingControlDueDisconnected"));
+ NetworkPlayer player = event.getPlayer();
+ broadcastServerMessage(player,
+ MessageFormat.format(LangUtil.getString("rukkit.playerLeft"),
+ player.name, event.getReason()));
+ if (isGaming(player)) {
+ player.sendTeamMessage(LangUtil.getString("rukkit.playerSharingControlDueDisconnected"));
}
- LoggerFactory.getLogger("Room #" + event.getPlayer().getRoom().roomId).info("Player {} left!({})", event.getPlayer().name, event.getReason());
- event.getPlayer().savePlayerData();
+ LoggerFactory.getLogger("Room #" + roomId(player)).info("Player {} left!({})",
+ player.name, event.getReason());
+ player.savePlayerData();
}
@EventHandler
public void onPlayerChatInfo(PlayerChatEvent event) {
- LoggerFactory.getLogger("Room #" + event.getPlayer().getRoom().roomId).info("[{}] {}", event.getPlayer().name, event.getMessage());
+ LoggerFactory.getLogger("Room #" + roomId(event.getPlayer())).info("[{}] {}",
+ event.getPlayer().name, event.getMessage());
}
@EventHandler
public void onPlayerReconnected(PlayerReconnectEvent event) {
- event.getPlayer().getRoom().connectionManager.broadcastServerMessage(MessageFormat.format(LangUtil.getString("rukkit.playerReconnect"), event.getPlayer().name));
- LoggerFactory.getLogger("Room #" + event.getPlayer().getRoom().roomId).info("Player {} reconnected!", event.getPlayer().name);
+ NetworkPlayer player = event.getPlayer();
+ broadcastServerMessage(player,
+ MessageFormat.format(LangUtil.getString("rukkit.playerReconnect"), player.name));
+ LoggerFactory.getLogger("Room #" + roomId(player)).info("Player {} reconnected!", player.name);
+ }
+
+ private static void broadcastServerMessage(NetworkPlayer player, String message) {
+ if (player.getServerRoom() != null) {
+ player.getServerRoom().connectionManager.broadcastServerMessage(message);
+ } else if (player.getRoom() != null) {
+ player.getRoom().connectionManager.broadcastServerMessage(message);
+ }
+ }
+
+ private static boolean isGaming(NetworkPlayer player) {
+ if (player.getServerRoom() != null) {
+ return player.getServerRoom().isGaming();
+ }
+ return player.getRoom() != null && player.getRoom().isGaming();
+ }
+
+ private static int roomId(NetworkPlayer player) {
+ if (player.getServerRoom() != null) {
+ return player.getServerRoom().roomId;
+ }
+ NetworkRoom room = player.getRoom();
+ return room == null ? -1 : room.roomId;
}
@Override
diff --git a/src/main/java/cn/rukkit/plugin/internal/CoreCommandPlugin.java b/src/main/java/cn/rukkit/plugin/internal/CoreCommandPlugin.java
new file mode 100644
index 0000000..cdd4f48
--- /dev/null
+++ b/src/main/java/cn/rukkit/plugin/internal/CoreCommandPlugin.java
@@ -0,0 +1,852 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.plugin.internal;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.command.ChatCommand;
+import cn.rukkit.command.ChatCommandContext;
+import cn.rukkit.command.ChatCommandContextListener;
+import cn.rukkit.command.CommandManager;
+import cn.rukkit.command.RoomCommandContext;
+import cn.rukkit.event.EventHandler;
+import cn.rukkit.event.EventListener;
+import cn.rukkit.event.player.PlayerChatEvent;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.PingType;
+import cn.rukkit.game.PlayerManager;
+import cn.rukkit.game.map.CustomMapLoader;
+import cn.rukkit.game.map.OfficialMap;
+import cn.rukkit.network.core.packet.UniversalPacket;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.plugin.PluginConfig;
+import cn.rukkit.util.LangUtil;
+import cn.rukkit.util.VersionUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.List;
+
+/**
+ * Chat commands owned by the migrated network stack.
+ *
+ * This plugin intentionally has no legacy {@code RoomConnection} listener.
+ * The old command implementation remains in {@link CommandPlugin}; keeping
+ * the registrations separate makes it impossible for core startup to
+ * accidentally dispatch a command through the old connection model.
+ */
+public class CoreCommandPlugin extends InternalRukkitPlugin
+ implements ChatCommandContextListener {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CoreCommandPlugin.class);
+ private int totalInfo;
+
+ /** Stops an in-progress AFK vote when the administrator chats. */
+ public class CommandEventListener implements EventListener {
+ @EventHandler
+ public void playerChat(PlayerChatEvent event) {
+ NetworkPlayer player = event.getPlayer();
+ if (player.getServerRoom() == null || !player.isAdmin) {
+ return;
+ }
+ if ("afk".equals(player.getServerRoom().vote.voteId)) {
+ player.getServerRoom().connectionManager
+ .broadcastServerMessage("Countdown stopped!");
+ player.getServerRoom().vote.stopVote();
+ }
+ }
+ }
+
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ context.sendServerMessage(versionMessage());
+ return false;
+ }
+
+ private static String versionMessage() {
+ return "Rukkit Server v" + VersionUtil.getVersion() + "\n"
+ + "Rukkit Plugin API v" + Rukkit.PLUGIN_API_VERSION;
+ }
+
+ @Override
+ public void loadConfig() {
+ config = new PluginConfig();
+ config.name = "Core Chat Command Plugin";
+ config.author = "rukkit";
+ config.version = VersionUtil.getVersion();
+ config.id = "core-command-plugin";
+ config.pluginClass = "cn.rukkit.plugin.internal.CoreCommandPlugin";
+ config.apiVersion = Rukkit.PLUGIN_API_VERSION;
+ }
+
+ private static RoomCommandContext roomContext(ChatCommandContext context) {
+ return context instanceof RoomCommandContext room ? room : null;
+ }
+
+ private static void broadcastServerInfo(RoomCommandContext context) {
+ try {
+ context.room().connectionManager.broadcast(
+ UniversalPacket.serverInfo(context.room().config));
+ context.connection().sendPacket(
+ UniversalPacket.serverInfo(context.room().config, true));
+ } catch (IOException ignored) {
+ // Keep command dispatch alive if a client channel closes mid-command.
+ }
+ }
+
+ private void register(CommandManager manager, String command, String help,
+ int args, boolean adminRequired,
+ ChatCommandContextListener listener) {
+ manager.registerCommand(ChatCommand.contextCommand(command, help, args,
+ listener, this, adminRequired));
+ }
+
+ public static class VersionCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ context.sendServerMessage(versionMessage());
+ return false;
+ }
+ }
+
+ public static class KickCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || args.length < 1) {
+ return true;
+ }
+ try {
+ NetworkPlayer player = room.room().playerManager
+ .get(Integer.parseInt(args[0]));
+ if (player == null || player.isEmpty || player.getServerConnection() == null) {
+ context.sendServerMessage(LangUtil.getString("chat.playerEmpty"));
+ } else {
+ player.getServerConnection().kick(LangUtil.getString("chat.kicked"));
+ }
+ } catch (NumberFormatException e) {
+ context.sendServerMessage(LangUtil.getString("chat.playerEmpty"));
+ }
+ return true;
+ }
+ }
+
+ public static class TeamChatCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ if (args.length > 0) {
+ context.player().sendTeamMessage(args[0]);
+ }
+ return false;
+ }
+ }
+
+ public static class MapsCallback implements ChatCommandContextListener {
+ private final int type;
+
+ public MapsCallback(int type) {
+ this.type = type;
+ }
+
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ if (type == 0) {
+ StringBuilder build = new StringBuilder();
+ int page = MapCommandSupport.pageIndex(args);
+ if (page < 0) {
+ return false;
+ }
+ if (args.length > 0) {
+ build.append("- Maps - Page ").append(args[0]).append(" \n");
+ for (int i = page * MapCommandSupport.PAGE_SIZE;
+ i < MapCommandSupport.pageEnd(page, OfficialMap.maps.length); i++) {
+ build.append(String.format("[%d] %s", i, OfficialMap.maps[i]))
+ .append("\n");
+ }
+ } else {
+ build.append("- Help - Page 1 \n");
+ for (int i = 0; i < 10 && i < OfficialMap.maps.length; i++) {
+ build.append(String.format("[%d] %s", i, OfficialMap.maps[i]))
+ .append("\n");
+ }
+ }
+ context.sendServerMessage(build.toString());
+ return false;
+ }
+
+ if (!context.player().isAdmin || args.length < 1) {
+ return false;
+ }
+ String mapName = null;
+ String mapString = MapCommandSupport.quotedValue(args[0]);
+ int mapIndex = mapString == null
+ ? MapCommandSupport.mapIndex(args[0], OfficialMap.maps.length)
+ : MapCommandSupport.officialMapIndex(mapString);
+ if (mapIndex >= 0) {
+ mapName = OfficialMap.maps[mapIndex];
+ } else {
+ return false;
+ }
+ if (mapName != null) {
+ room.room().config.mapName = mapName;
+ room.room().config.mapType = 0;
+ broadcastServerInfo(room);
+ }
+ return false;
+ }
+ }
+
+ public static class CustomMapsCallback implements ChatCommandContextListener {
+ private final int type;
+
+ public CustomMapsCallback(int type) {
+ this.type = type;
+ }
+
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ List maps = CustomMapLoader.getMapNameList();
+ if (type == 0) {
+ StringBuilder build = new StringBuilder();
+ int page = MapCommandSupport.pageIndex(args);
+ if (page < 0) {
+ return false;
+ }
+ if (args.length > 0) {
+ build.append("- CustomMaps - Page ").append(args[0]).append(" \n");
+ for (int i = page * MapCommandSupport.PAGE_SIZE;
+ i < MapCommandSupport.pageEnd(page, maps.size()); i++) {
+ build.append(String.format("[%d] %s", i, maps.get(i)))
+ .append("\n");
+ }
+ } else {
+ build.append("- Help - Page 1 \n");
+ for (int i = 0; i < Math.min(maps.size(), 10); i++) {
+ build.append(String.format("[%d] %s", i, maps.get(i)))
+ .append("\n");
+ }
+ }
+ context.sendServerMessage(build.toString());
+ return false;
+ }
+
+ if (context.player().isAdmin && args.length > 0) {
+ int mapIndex = MapCommandSupport.mapIndex(args[0], maps.size());
+ if (mapIndex < 0) {
+ return false;
+ }
+ room.room().config.mapName = maps.get(mapIndex);
+ room.room().config.mapType = 1;
+ broadcastServerInfo(room);
+ }
+ return false;
+ }
+ }
+
+ class MoveCallback implements ChatCommandContextListener {
+ private final int type;
+
+ MoveCallback(int type) {
+ this.type = type;
+ }
+
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ if (type == 0) {
+ movePlayer(room, context, args);
+ } else if (type == 1) {
+ moveSelf(room, context, args);
+ }
+ return false;
+ }
+
+ private void movePlayer(RoomCommandContext room, ChatCommandContext context,
+ String[] args) {
+ if (!context.player().isAdmin || room.room().isGaming() || args.length < 2) {
+ return;
+ }
+ try {
+ PlayerManager players = room.room().playerManager;
+ NetworkPlayer from = players.get(Integer.parseInt(args[0]) - 1);
+ NetworkPlayer target = players.get(Integer.parseInt(args[1]) - 1);
+ if (from == null || target == null || from.isEmpty || target.isEmpty) {
+ context.sendServerMessage(LangUtil.getString("chat.playerEmpty"));
+ return;
+ }
+ if (args.length == 3) {
+ int team = Integer.parseInt(args[2]);
+ from.team = (team == -1 || team == -2)
+ ? (target.playerIndex % 2 == 1 ? 1 : 0) : team;
+ }
+ if (from.movePlayer(Integer.parseInt(args[1]) - 1)) {
+ context.sendServerMessage(LangUtil.getString("chat.moveComplete"));
+ return;
+ }
+ int fromSlot = from.playerIndex;
+ int toSlot = target.playerIndex;
+ if (fromSlot == toSlot) {
+ context.sendServerMessage("not same player!");
+ return;
+ }
+ players.remove(target);
+ from.movePlayer(toSlot);
+ target.movePlayer(fromSlot);
+ } catch (RuntimeException e) {
+ LOG.error("Error moving player", e);
+ }
+ }
+
+ private void moveSelf(RoomCommandContext room, ChatCommandContext context,
+ String[] args) {
+ if (room.room().isGaming() || args.length < 1) {
+ return;
+ }
+ try {
+ if (args.length == 2) {
+ int team = Integer.parseInt(args[1]);
+ context.player().team = (team == -1 || team == -2)
+ ? ((Integer.parseInt(args[0]) - 1) % 2 == 1 ? 1 : 0)
+ : team;
+ }
+ if (context.player().movePlayer(Integer.parseInt(args[0]) - 1)) {
+ context.sendServerMessage(LangUtil.getString("chat.moveComplete"));
+ } else {
+ context.sendServerMessage(LangUtil.getString("chat.playerExist"));
+ }
+ } catch (RuntimeException e) {
+ LOG.error("Error moving player", e);
+ }
+ }
+ }
+
+ class QcCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ if (!(context instanceof RoomCommandContext room) || args.length == 0) {
+ return false;
+ }
+ getLogger().info("Player {} issued command: {}", context.player().name, args[0]);
+ String nested = CommandManager.normalizeNestedCommand(args[0]);
+ Rukkit.getCommandManager().executeChatCommand(
+ room.connection(), Rukkit.getCoreGlobalConnectionManager(), nested);
+ return false;
+ }
+ }
+
+ class TeamCallback implements ChatCommandContextListener {
+ private final int type;
+
+ TeamCallback(int type) {
+ this.type = type;
+ }
+
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming()
+ || args.length < (type == 0 ? 2 : 1)) {
+ return false;
+ }
+ try {
+ if (type == 0) {
+ if (!context.player().isAdmin) {
+ return false;
+ }
+ int team = Integer.parseInt(args[1]) - 1;
+ int slot = Integer.parseInt(args[0]) - 1;
+ NetworkPlayer target = room.room().playerManager.get(slot);
+ if (target == null || target.isEmpty) {
+ context.sendServerMessage(LangUtil.getString("chat.playerEmpty"));
+ return false;
+ }
+ if (team == -1 || team == -2) {
+ target.team = slot % 2 == 1 ? 1 : 2;
+ }
+ target.team = team;
+ } else {
+ context.player().team = Integer.parseInt(args[0]) - 1;
+ }
+ } catch (RuntimeException e) {
+ context.sendServerMessage(LangUtil.getString("chat.playerEmpty"));
+ }
+ return false;
+ }
+ }
+
+ static class HelpCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ context.sendServerMessage(buildHelpMessage(args));
+ return false;
+ }
+
+ private static String buildHelpMessage(String[] args) {
+ StringBuilder build = new StringBuilder();
+ if (args.length > 0) {
+ build.append("- Help - Page ").append(args[0]).append(" \n");
+ int page = Integer.parseInt(args[0]) - 1;
+ int index = 0;
+ for (ChatCommand command : Rukkit.getCommandManager().getLoadedCommand().values()) {
+ if (index++ < page * 10) {
+ continue;
+ }
+ if (index > page * 10 + 10) {
+ break;
+ }
+ build.append(String.format("%s : %s", command.cmd, command.helpMessage))
+ .append("\n");
+ }
+ } else {
+ build.append("- Help - Page 1 \n");
+ int index = 0;
+ for (ChatCommand command : Rukkit.getCommandManager().getLoadedCommand().values()) {
+ if (index++ > 10) {
+ break;
+ }
+ build.append(String.format("%s : %s", command.cmd, command.helpMessage))
+ .append("\n");
+ }
+ }
+ return build.toString();
+ }
+ }
+
+ class InfoCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ totalInfo++;
+ LOG.warn("{} send a info: {}", context.player().name,
+ args.length == 0 ? "" : args[0]);
+ return false;
+ }
+ }
+
+ class StartCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return true;
+ }
+ if (!room.room().isGaming() && context.player().isAdmin) {
+ if (room.room().connectionManager.size() < Rukkit.getConfig().minStartPlayer) {
+ room.room().connectionManager.broadcastServerMessage(
+ MessageFormat.format(LangUtil.getString("chat.minStartPlayer"),
+ Rukkit.getConfig().minStartPlayer));
+ } else {
+ room.room().startGame();
+ }
+ }
+ return true;
+ }
+ }
+
+ class SetFogCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ switch (args[0]) {
+ case "off" -> room.room().config.fogType = 0;
+ case "basic" -> room.room().config.fogType = 1;
+ case "los" -> room.room().config.fogType = 2;
+ default -> room.room().config.fogType = 2;
+ }
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class StartingUnitCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ room.room().config.startingUnits = Integer.parseInt(args[0]);
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class ShareCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || context.player() == null) {
+ return false;
+ }
+ if (!room.room().config.sharedControl) {
+ context.sendServerMessage("[Shared control is not enabled in this game]");
+ return false;
+ }
+
+ String value = args != null && args.length > 0 ? args[0] : "";
+ if ("true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value)) {
+ if (!context.player().isSharingControl) {
+ context.player().isSharingControl = true;
+ room.room().connectionManager.broadcastServerMessage(
+ "[shared control now on for " + context.player().name + "]");
+ } else {
+ room.room().connectionManager.broadcastServerMessage(
+ "[shared control already on for " + context.player().name + "]");
+ }
+ return false;
+ }
+ if ("false".equalsIgnoreCase(value) || "off".equalsIgnoreCase(value)) {
+ if (context.player().isSharingControl) {
+ context.player().isSharingControl = false;
+ room.room().connectionManager.broadcastServerMessage(
+ "[shared control now off for " + context.player().name + "]");
+ } else {
+ room.room().connectionManager.broadcastServerMessage(
+ "[shared control already off for " + context.player().name + "]");
+ }
+ return false;
+ }
+ context.sendServerMessage("[Expected true or false]");
+ return false;
+ }
+ }
+
+ class SharedControlCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ room.room().config.sharedControl = Boolean.parseBoolean(args[0]);
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class NukeCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ room.room().config.disableNuke = !Boolean.parseBoolean(args[0]);
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class IncomeCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ float income = Float.parseFloat(args[0]);
+ if (income > 100 || income < 0) {
+ income = 1;
+ }
+ room.room().config.income = income;
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class CreditsCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || room.room().isGaming() || !context.player().isAdmin
+ || args.length < 1) {
+ return false;
+ }
+ room.room().config.credits = Integer.parseInt(args[0]);
+ broadcastServerInfo(room);
+ return false;
+ }
+ }
+
+ class SyncCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room != null && room.room().isGaming()) {
+ room.room().vote.submitVoting(room.room()::syncGame,
+ "sync", "有玩家发起了同步!输入-y或者-n来投票!", 15);
+ }
+ return false;
+ }
+ }
+
+ class AgreeCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ if (room.room().vote.disabledVote) {
+ context.sendServerMessage("投票已禁用!");
+ } else if (room.room().vote.isVoting) {
+ context.sendServerMessage(room.room().vote.agree(context.player().playerIndex)
+ ? LangUtil.getString("nostop.vote.submit")
+ : LangUtil.getString("nostop.vote.alreadySubmit"));
+ } else {
+ context.sendServerMessage(LangUtil.getString("nostop.vote.noCurrentVote"));
+ }
+ return false;
+ }
+ }
+
+ class DisagreeCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ if (room.room().vote.disabledVote) {
+ context.sendServerMessage("投票已禁用!");
+ } else if (room.room().vote.isVoting) {
+ context.sendServerMessage(room.room().vote.disagree(context.player().playerIndex)
+ ? LangUtil.getString("nostop.vote.submit")
+ : LangUtil.getString("nostop.vote.alreadySubmit"));
+ } else {
+ context.sendServerMessage(LangUtil.getString("nostop.vote.noCurrentVote"));
+ }
+ return false;
+ }
+ }
+
+ class ChksumCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ try {
+ room.room().broadcast(
+ UniversalPacket.syncCheckSum(room.room().getCurrentStep()));
+ } catch (IOException e) {
+ LOG.error("Error sending checksum request", e);
+ }
+ return false;
+ }
+ }
+
+ class PingCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || args.length < 2) {
+ return false;
+ }
+ try {
+ float x = Float.parseFloat(args[0]);
+ float y = Float.parseFloat(args[1]);
+ room.room().broadcast(UniversalPacket.gamePing(room.room(),
+ context.player().playerIndex, PingType.happy, x, y));
+ } catch (IOException e) {
+ LOG.error("Error sending ping packet", e);
+ }
+ return false;
+ }
+ }
+
+ static class StateCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ context.sendServerMessage(buildStateMessage(context.connectionCount(),
+ context.activeThreadCount(), context.threadPoolCount()));
+ return false;
+ }
+
+ private static String buildStateMessage(int connections, int activeThreads,
+ int threadPoolCount) {
+ StringBuilder build = new StringBuilder();
+ build.append("- State - \n");
+ build.append("RAM Usage: ").append(Runtime.getRuntime().freeMemory() / 10240)
+ .append("M/").append(Runtime.getRuntime().totalMemory() / 10240)
+ .append("M\n");
+ build.append("Connections: ").append(connections);
+ build.append("ThreadManager Tasks: ").append(activeThreads).append("/")
+ .append(threadPoolCount);
+ return build.toString();
+ }
+ }
+
+ class PlayerListCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null) {
+ return false;
+ }
+ StringBuilder buffer = new StringBuilder("- Players -\n");
+ for (ServerRoomConnection connection : room.room().connectionManager.getConnections()) {
+ buffer.append(String.format("%s (Team %d) (%d ms)\n",
+ connection.player.name,
+ connection.player.team,
+ System.currentTimeMillis() - connection.pingTime));
+ }
+ context.sendServerMessage(buffer.toString());
+ return false;
+ }
+ }
+
+ class SurrenderCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || context.player().isSurrounded) {
+ return false;
+ }
+ try {
+ room.room().broadcast(UniversalPacket.gameSurrounder(
+ room.room(), context.player().playerIndex));
+ room.room().connectionManager.broadcastServerMessage(
+ String.format("Player %s surrounded!", context.player().name));
+ context.player().isSurrounded = true;
+ } catch (IOException e) {
+ LOG.error("Error sending surrender packet", e);
+ }
+ return false;
+ }
+ }
+
+ static class AfkCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ RoomCommandContext room = roomContext(context);
+ if (room == null || context.player() == room.room().playerManager.getAdmin()) {
+ return false;
+ }
+ room.room().vote.disabledVote = true;
+ room.room().vote.submitVoting(() -> {
+ NetworkPlayer formerAdmin = room.room().playerManager.getAdmin();
+ if (formerAdmin != null) {
+ formerAdmin.giveAdmin(context.player().playerIndex);
+ formerAdmin.updateServerInfo();
+ }
+ context.player().updateServerInfo();
+ }, "afk", LangUtil.getFormatString("chat.vote.afk", context.player().name), 30);
+ return false;
+ }
+ }
+
+ @Override
+ public void onLoad() {
+ getLogger().info("CoreCommandPlugin::onLoad()");
+ CommandManager manager = Rukkit.getCommandManager();
+ register(manager, "help", LangUtil.getString("chat.help"), 1, false,
+ new HelpCallback());
+ register(manager, "state", LangUtil.getString("chat.state"), 0, false,
+ new StateCallback());
+ register(manager, "version", LangUtil.getString("chat.version"), 0, false,
+ this);
+ register(manager, "t", LangUtil.getString("chat.t"), 1, false,
+ new TeamChatCallback());
+ register(manager, "maps", LangUtil.getString("chat.maps"), 1, false,
+ new MapsCallback(0));
+ register(manager, "map", LangUtil.getString("chat.map"), 1, true,
+ new MapsCallback(1));
+ register(manager, "cmaps", LangUtil.getString("chat.cmaps"), 1, false,
+ new CustomMapsCallback(0));
+ register(manager, "cmap", LangUtil.getString("chat.cmap"), 1, true,
+ new CustomMapsCallback(1));
+ register(manager, "kick", LangUtil.getString("chat.kick"), 1, true,
+ new KickCallback());
+ register(manager, "team", LangUtil.getString("chat.team"), 2, true,
+ new TeamCallback(0));
+ register(manager, "self_team", LangUtil.getString("chat.self_team"), 1, false,
+ new TeamCallback(1));
+ register(manager, "move", LangUtil.getString("chat.move"), 3, true,
+ new MoveCallback(0));
+ register(manager, "self_move", LangUtil.getString("chat.self_move"), 2, false,
+ new MoveCallback(1));
+ register(manager, "qc", LangUtil.getString("chat.qc"), 1, false,
+ new QcCallback());
+ register(manager, "fog", LangUtil.getString("chat.fog"), 1, true,
+ new SetFogCallback());
+ register(manager, "nukes", LangUtil.getString("chat.nukes"), 1, true,
+ new NukeCallback());
+ register(manager, "startingunits", LangUtil.getString("chat.startingunits"), 1, true,
+ new StartingUnitCallback());
+ register(manager, "income", LangUtil.getString("chat.income"), 1, true,
+ new IncomeCallback());
+ register(manager, "share", LangUtil.getString("chat.share"), 1, false,
+ new ShareCallback());
+ register(manager, "credits", LangUtil.getString("chat.credits"), 1, true,
+ new CreditsCallback());
+ register(manager, "start", LangUtil.getString("chat.start"), 1, true,
+ new StartCallback());
+ register(manager, "sync", LangUtil.getString("chat.sync"), 0, true,
+ new SyncCallback());
+ register(manager, "i", LangUtil.getString("chat.i"), 1, false,
+ new InfoCallback());
+ register(manager, "chksum", LangUtil.getString("chat.chksum"), 0, false,
+ new ChksumCallback());
+ register(manager, "maping", LangUtil.getString("chat.maping"), 2, false,
+ new PingCallback());
+ register(manager, "list", LangUtil.getString("chat.list"), 0, false,
+ new PlayerListCallback());
+ register(manager, "surrender", LangUtil.getString("chat.surrender"), 0, false,
+ new SurrenderCallback());
+ register(manager, "afk", LangUtil.getString("chat.afk"), 0, false,
+ new AfkCallback());
+ register(manager, "y", LangUtil.getString("nostop.y"), 0, false,
+ new AgreeCallback());
+ register(manager, "n", LangUtil.getString("nostop.n"), 0, false,
+ new DisagreeCallback());
+ getPluginManager().registerEventListener(new CommandEventListener(), this);
+ }
+
+ @Override
+ public void onEnable() {
+ getLogger().info("CoreCommandPlugin::onEnable()");
+ }
+
+ @Override
+ public void onDisable() {
+ // No command-specific resources.
+ }
+
+ @Override
+ public void onStart() {
+ // No command-specific resources.
+ }
+
+ @Override
+ public void onDone() {
+ // No command-specific resources.
+ }
+}
diff --git a/src/main/java/cn/rukkit/plugin/internal/CoreTestCommandPlugin.java b/src/main/java/cn/rukkit/plugin/internal/CoreTestCommandPlugin.java
new file mode 100644
index 0000000..5565c3c
--- /dev/null
+++ b/src/main/java/cn/rukkit/plugin/internal/CoreTestCommandPlugin.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.plugin.internal;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.command.ChatCommand;
+import cn.rukkit.command.ChatCommandContext;
+import cn.rukkit.command.ChatCommandContextListener;
+import cn.rukkit.command.CommandManager;
+import cn.rukkit.command.RoomCommandContext;
+import cn.rukkit.event.EventHandler;
+import cn.rukkit.event.EventListener;
+import cn.rukkit.event.action.PingEvent;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.plugin.PluginConfig;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Core-network counterpart of the legacy test/debug command plugin. */
+public class CoreTestCommandPlugin extends InternalRukkitPlugin implements EventListener {
+
+ private TestPluginConfig testConfig = new TestPluginConfig();
+
+ class TestSyncCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ if (context instanceof RoomCommandContext room) {
+ room.room().syncGame();
+ }
+ return false;
+ }
+ }
+
+ class SummonCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ if (!(context instanceof RoomCommandContext room)) {
+ return false;
+ }
+ if (!room.room().isGaming()) {
+ context.sendServerMessage("游戏未开始!");
+ return false;
+ }
+ if (args.length >= 1) {
+ long previous = (long) context.player().getTempData("lastSummonTime", 0L);
+ long now = System.currentTimeMillis();
+ if (now - previous < testConfig.cd) {
+ context.sendServerMessage("请等待" + ((now - previous) / 1000) + "秒");
+ return false;
+ }
+ context.sendServerMessage("请PING一个位置");
+ context.player().putTempData("spawnUnit", args[0]);
+ context.player().putTempData("isSpawnTriggered", true);
+ context.player().putTempData("lastSummonTime", now);
+ }
+ return false;
+ }
+ }
+
+ @EventHandler
+ public void onPing(PingEvent event) {
+ NetworkPlayer player = event.getPlayer();
+ if (!(player.getServerRoom() != null)
+ || !(boolean) player.getTempData("isSpawnTriggered", false)) {
+ return;
+ }
+ String unit = (String) player.getTempData("spawnUnit", "tank");
+ if (unit.equals("editorOrBuilder") && !player.isAdmin
+ && player.getServerConnection() != null) {
+ player.getServerConnection().sendServerMessage("只有管理才可以生成该单位!");
+ }
+ player.getServerRoom().summonUnit(unit, event.getTargetX(), event.getTargetY(),
+ player.playerIndex);
+ player.putTempData("isSpawnTriggered", false);
+ }
+
+ class StopCallback implements ChatCommandContextListener {
+ @Override
+ public boolean onSend(ChatCommandContext context, String[] args) {
+ if (context instanceof RoomCommandContext room) {
+ room.room().stopGame(true);
+ }
+ return false;
+ }
+ }
+
+ @Override
+ public void onLoad() {
+ getLogger().info("CoreTestCommandPlugin is loading...");
+ testConfig = new TestPluginConfig();
+ getPluginManager().registerEventListener(this, this);
+ try {
+ File pluginFile = getConfigFile("config");
+ if (pluginFile.length() == 0) {
+ saveConfig(pluginFile, testConfig);
+ }
+ testConfig = getConfig(pluginFile, TestPluginConfig.class);
+ } catch (IOException e) {
+ getLogger().warn("Config cannot be loaded.");
+ }
+
+ CommandManager manager = Rukkit.getCommandManager();
+ manager.registerCommand(ChatCommand.contextCommand("summon", "Summon a unit.", 1,
+ new SummonCallback(), this));
+ manager.registerCommand(ChatCommand.contextCommand("gamestop",
+ "Stop a game immidately and return to the battleroom", 0,
+ new StopCallback(), this));
+ manager.registerCommand(ChatCommand.contextCommand("testsync", "Sync", 0,
+ new TestSyncCallback(), this));
+ }
+
+ @Override
+ public void onEnable() {
+ }
+
+ @Override
+ public void onDisable() {
+ }
+
+ @Override
+ public void onStart() {
+ getLogger().info("Core test plugin is starting..");
+ }
+
+ @Override
+ public void onDone() {
+ }
+
+ @Override
+ public void loadConfig() {
+ config = new PluginConfig();
+ config.name = "CoreTestPlugin";
+ config.author = "rukkit";
+ config.version = "1.0.0";
+ config.id = "core-test-plugin";
+ config.pluginClass = "cn.rukkit.plugin.internal.CoreTestCommandPlugin";
+ config.apiVersion = Rukkit.PLUGIN_API_VERSION;
+ }
+}
diff --git a/src/main/java/cn/rukkit/plugin/internal/MapCommandSupport.java b/src/main/java/cn/rukkit/plugin/internal/MapCommandSupport.java
new file mode 100644
index 0000000..78d31d9
--- /dev/null
+++ b/src/main/java/cn/rukkit/plugin/internal/MapCommandSupport.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.plugin.internal;
+
+import cn.rukkit.game.map.OfficialMap;
+
+/** Shared, side-effect-free parsing rules for map chat commands. */
+final class MapCommandSupport {
+ static final int PAGE_SIZE = 10;
+
+ private MapCommandSupport() {
+ }
+
+ /** Returns a zero-based page index, or {@code -1} for an invalid page. */
+ static int pageIndex(String[] args) {
+ if (args == null || args.length == 0) {
+ return 0;
+ }
+ try {
+ int page = Integer.parseInt(args[0]);
+ return page > 0 ? page - 1 : -1;
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ static int pageEnd(int pageIndex, int size) {
+ return Math.min(pageIndex * PAGE_SIZE + PAGE_SIZE, size);
+ }
+
+ /** Returns a valid zero-based index, or {@code -1} when the value is invalid. */
+ static int mapIndex(String value, int size) {
+ try {
+ int index = Integer.parseInt(value);
+ return index >= 0 && index < size ? index : -1;
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /** Extracts the text between the first and last single quote. */
+ static String quotedValue(String value) {
+ if (value == null || !value.startsWith("'")) {
+ return null;
+ }
+ int end = value.lastIndexOf('\'');
+ return end > 0 ? value.substring(1, end) : value.substring(1);
+ }
+
+ /** Matches both the user-facing map name and the resource name shown by {@code maps}. */
+ static int officialMapIndex(String query) {
+ if (query == null || query.isEmpty()) {
+ return -1;
+ }
+ int size = Math.min(OfficialMap.maps.length, OfficialMap.mapsName.length);
+ for (int i = 0; i < size; i++) {
+ if (OfficialMap.mapsName[i].contains(query)
+ || OfficialMap.maps[i].contains(query)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/cn/rukkit/plugin/internal/ServerCommandPlugin.java b/src/main/java/cn/rukkit/plugin/internal/ServerCommandPlugin.java
index 078cb2c..4a153ec 100644
--- a/src/main/java/cn/rukkit/plugin/internal/ServerCommandPlugin.java
+++ b/src/main/java/cn/rukkit/plugin/internal/ServerCommandPlugin.java
@@ -20,7 +20,10 @@
import cn.rukkit.network.NetworkRoom;
import cn.rukkit.network.RoomConnection;
import cn.rukkit.network.RoomManager;
+import cn.rukkit.network.core.packet.UniversalPacket;
import cn.rukkit.network.packet.Packet;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
import cn.rukkit.plugin.PluginConfig;
import cn.rukkit.util.LangUtil;
import cn.rukkit.util.VersionUtil;
@@ -49,6 +52,19 @@ public void onSend(String[] args) {
if (args.length >= 2) {
int roomid = Integer.parseInt(args[0]);
int slot = Integer.parseInt(args[1]);
+ if (Rukkit.isCoreNetworkEnabled()) {
+ ServerRoom room = Rukkit.getCoreRoomManager().getRoom(roomid);
+ if (room == null) return;
+ NetworkPlayer player = room.playerManager.get(slot);
+ if (player != null && !player.isSurrounded) {
+ try {
+ room.broadcast(UniversalPacket.gameSurrounder(room, slot));
+ } catch (IOException e) {
+ getLogger().error("An error occurred:", e);
+ }
+ }
+ return;
+ }
NetworkRoom room = Rukkit.getRoomManager().getRoom(roomid);
if (room == null) return;
NetworkPlayer player = room.playerManager.get(slot);
@@ -70,7 +86,10 @@ public void onSend(String[] args) {
StringBuilder build = new StringBuilder();
build.append("- State - \n");
build.append("RAM Usage: " + (Runtime.getRuntime().freeMemory() / 10240) + "M/" + (Runtime.getRuntime().totalMemory()) / 10240 + "M\n");
- build.append("Connections: " + Rukkit.getGlobalConnectionManager().size() + "\n");
+ int connections = Rukkit.isCoreNetworkEnabled()
+ ? Rukkit.getCoreGlobalConnectionManager().size()
+ : Rukkit.getGlobalConnectionManager().size();
+ build.append("Connections: " + connections + "\n");
build.append("ThreadManager Tasks: " + Rukkit.getThreadManager().getActiveThreadCount() + "/" + Rukkit.getConfig().threadPoolCount);
System.out.println(build);
}
@@ -80,6 +99,21 @@ class PlayerListCallback implements ServerCommandListener {
@Override
public void onSend(String[] args) {
StringBuffer buffer = new StringBuffer("- Players -\n");
+ if (Rukkit.isCoreNetworkEnabled()) {
+ for (ServerRoom room : Rukkit.getCoreRoomManager().roomList) {
+ buffer.append(MessageFormat.format(
+ "- Room #{0} (gaming={1})(step={2}) -\n",
+ room.roomId, room.isGaming(), room.getCurrentStep()));
+ for (ServerRoomConnection connection : room.connectionManager.getConnections()) {
+ buffer.append(MessageFormat.format("[{0}] {1} ping={2}\n",
+ connection.player.playerIndex,
+ connection.player.name,
+ connection.player.ping));
+ }
+ }
+ System.out.println(buffer);
+ return;
+ }
for (NetworkRoom networkRoom: Rukkit.getRoomManager().roomList) {
buffer.append(MessageFormat.format("- Room #{0} (gaming={1})(step={2}) -\n", networkRoom.roomId, networkRoom.isGaming(), networkRoom.getCurrentStep()));
for (RoomConnection connection: networkRoom.connectionManager.getConnections()) {
@@ -97,6 +131,20 @@ public void onSend(String[] args) {
if (args.length >= 2) {
int roomid = Integer.parseInt(args[0]);
int playerid = Integer.parseInt(args[1]);
+ if (Rukkit.isCoreNetworkEnabled()) {
+ ServerRoom room = Rukkit.getCoreRoomManager().getRoom(roomid);
+ if (room == null) return;
+ if (room.isGaming()) {
+ System.out.println("Failed: this room is in game!");
+ }
+ NetworkPlayer player = room.playerManager.get(playerid);
+ if (player != null && !player.isEmpty && player.getServerConnection() != null) {
+ player.getServerConnection().kick(LangUtil.getString("chat.kicked"));
+ } else {
+ System.out.println(LangUtil.getString("chat.playerEmpty"));
+ }
+ return;
+ }
if (Rukkit.getRoomManager().getRoom(roomid).isGaming()) {
System.out.println("Failed: this room is in game!");
}
@@ -140,6 +188,16 @@ class SayCallback implements ServerCommandListener {
@Override
public void onSend(String[] args) {
if (args.length >= 2) {
+ if (Rukkit.isCoreNetworkEnabled()) {
+ ServerRoom room = Rukkit.getCoreRoomManager().getRoom(
+ Integer.parseInt(args[0]));
+ if (room != null) {
+ room.connectionManager.broadcastServerMessage(args[1]);
+ LoggerFactory.getLogger("Room #" + room.roomId)
+ .info("[Server] {}", args[1]);
+ }
+ return;
+ }
Rukkit.getRoomManager().getRoom(Integer.parseInt(args[0])).connectionManager.broadcastServerMessage(args[1]);
LoggerFactory.getLogger("Room #" + Integer.parseInt(args[0])).info("[Server] {}", args[1]);
}
@@ -156,6 +214,11 @@ public void onSend(String[] args) {
class KickAllCallback implements ServerCommandListener {
@Override
public void onSend(String[] args) {
+ if (Rukkit.isCoreNetworkEnabled()) {
+ Rukkit.getCoreGlobalConnectionManager().broadcastGlobalServerMessage("Server kicked you.");
+ Rukkit.getCoreGlobalConnectionManager().disconnect();
+ return;
+ }
Rukkit.getGlobalConnectionManager().broadcastGlobalServerMessage("Server kicked you.");
Rukkit.getGlobalConnectionManager().disconnect();
}
@@ -189,6 +252,28 @@ public void onSend(String[] args) {
System.out.println(build);
} else {
if (args.length > 1) {
+ if (Rukkit.isCoreNetworkEnabled()) {
+ ServerRoom room = Rukkit.getCoreRoomManager().getRoom(
+ Integer.parseInt(args[0]));
+ if (room == null) return;
+ if (args[1].startsWith("'")) {
+ String mapString = args[1].split("'")[1];
+ for (int i = 0; i < OfficialMap.mapsName.length; i++) {
+ if (OfficialMap.mapsName[i].contains(mapString)) {
+ room.config.mapName = OfficialMap.maps[i];
+ room.config.mapType = 0;
+ try {
+ room.broadcast(UniversalPacket.serverInfo(room.config));
+ } catch (IOException ignored) {
+ }
+ return;
+ }
+ }
+ }
+ room.config.mapName = OfficialMap.maps[Integer.parseInt(args[1])];
+ room.config.mapType = 0;
+ return;
+ }
NetworkRoom room = Rukkit.getRoomManager().getRoom(Integer.parseInt(args[0]));
if (room == null) return;
if (args[1].startsWith("'")) {
diff --git a/src/test/java/cn/rukkit/command/CommandManagerServerConnectionTest.java b/src/test/java/cn/rukkit/command/CommandManagerServerConnectionTest.java
new file mode 100644
index 0000000..40f8c33
--- /dev/null
+++ b/src/test/java/cn/rukkit/command/CommandManagerServerConnectionTest.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.command;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.plugin.PluginConfig;
+import cn.rukkit.plugin.RukkitPlugin;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class CommandManagerServerConnectionTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxPlayer = 2;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(1);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void executesServerCommandWithArgumentsAndEchoesIt() throws Exception {
+ CommandManager manager = new CommandManager();
+ TestPlugin plugin = new TestPlugin();
+ ChatCommand command = new ChatCommand("echo", "", 1,
+ (connection, args) -> false, plugin);
+ command.setContextListener((context, args) -> {
+ assertEquals("hello world", args[0]);
+ context.sendServerMessage("executed");
+ return true;
+ });
+ manager.registerCommand(command);
+
+ ConnectionFixture fixture = newConnection();
+ manager.executeChatCommand(fixture.connection, "echo hello world");
+
+ Packet serverMessage = fixture.channel.readOutbound();
+ Packet echo = fixture.channel.readOutbound();
+ assertNotNull(serverMessage);
+ assertNotNull(echo);
+ assertEquals(PacketType.SEND_CHAT, serverMessage.type);
+ assertEquals(PacketType.SEND_CHAT, echo.type);
+ assertArrayEquals(
+ cn.rukkit.network.core.packet.UniversalPacket.chat("SERVER", "executed", -1).bytes,
+ serverMessage.bytes);
+ assertArrayEquals(
+ cn.rukkit.network.core.packet.UniversalPacket.chat("Alice", "-echo hello world", 0).bytes,
+ echo.bytes);
+ }
+
+ @Test
+ void appliesPermissionCheckBeforeContextListener() throws Exception {
+ CommandManager manager = new CommandManager();
+ TestPlugin plugin = new TestPlugin();
+ ChatCommand command = new ChatCommand("admin", "", 0,
+ (connection, args) -> false, plugin, true);
+ command.setContextListener((context, args) -> {
+ throw new AssertionError("permission denied command must not execute");
+ });
+ manager.registerCommand(command);
+
+ ConnectionFixture fixture = newConnection();
+ manager.executeChatCommand(fixture.connection, "admin");
+
+ Packet response = fixture.channel.readOutbound();
+ assertNotNull(response);
+ assertEquals(PacketType.SEND_CHAT, response.type);
+ assertArrayEquals(
+ cn.rukkit.network.core.packet.UniversalPacket.chat(
+ "SERVER", cn.rukkit.util.LangUtil.getString("chat.privDenied"), -1).bytes,
+ response.bytes);
+ }
+
+ private ConnectionFixture newConnection() {
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ new ServerPacketHandlerManager());
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = new ServerRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "uuid-command-test";
+ room.connectionManager.add(connection);
+ handler.setConn(connection);
+ handler.setState(ConnectionState.IN_ROOM);
+ return new ConnectionFixture(channel, connection);
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ConnectionFixture(EmbeddedChannel channel,
+ ServerRoomConnection connection) {
+ }
+
+ private static final class TestPlugin extends RukkitPlugin {
+ private TestPlugin() {
+ config = new PluginConfig();
+ config.name = "command-test";
+ config.id = "command-test";
+ }
+
+ @Override
+ public void onLoad() {
+ }
+
+ @Override
+ public void onEnable() {
+ }
+
+ @Override
+ public void onDisable() {
+ }
+
+ @Override
+ public void onStart() {
+ }
+
+ @Override
+ public void onDone() {
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/config/RukkitConfigTest.java b/src/test/java/cn/rukkit/config/RukkitConfigTest.java
new file mode 100644
index 0000000..32325ec
--- /dev/null
+++ b/src/test/java/cn/rukkit/config/RukkitConfigTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ */
+
+package cn.rukkit.config;
+
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RukkitConfigTest {
+ @Test
+ void defaultsToLegacyNetworkRuntime() {
+ RukkitConfig config = new RukkitConfig();
+
+ assertEquals("legacy", config.getNetworkMode());
+ assertFalse(config.isCoreNetworkEnabled());
+ }
+
+ @Test
+ void readsCoreNetworkModeFromNestedYamlConfig() {
+ RukkitConfig config = new Yaml().loadAs(
+ "network:\n mode: CORE\n", RukkitConfig.class);
+
+ assertEquals("core", config.getNetworkMode());
+ assertTrue(config.isCoreNetworkEnabled());
+ }
+
+ @Test
+ void unknownNetworkModeFallsBackToLegacy() {
+ RukkitConfig config = new RukkitConfig();
+ config.network.mode = "unsupported";
+
+ assertEquals("legacy", config.getNetworkMode());
+ assertFalse(config.isCoreNetworkEnabled());
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/CoreRoomGameServerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/CoreRoomGameServerBehaviorTest.java
new file mode 100644
index 0000000..3fb6e0d
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/CoreRoomGameServerBehaviorTest.java
@@ -0,0 +1,542 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ */
+
+package cn.rukkit.network.core;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.mod.ModManager;
+import cn.rukkit.event.player.PlayerJoinEvent;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketDecoder;
+import cn.rukkit.network.core.packet.PacketEncoder;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.service.ThreadManager;
+import cn.rukkit.plugin.internal.BasePlugin;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioSocketChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CoreRoomGameServerBehaviorTest {
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private Object previousModManager;
+ private Object previousStarted;
+ private ThreadManager threadManager;
+ private ServerRoomManager roomManager;
+ private ServerGlobalConnectionManager globalConnectionManager;
+ private CoreRoomGameServer server;
+ private Thread serverThread;
+ private NioEventLoopGroup clientGroup;
+ private Channel clientChannel;
+
+ @BeforeEach
+ void installTestRuntime() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 2;
+ config.maxPlayer = 1;
+ config.serverPort = 0;
+ config.UUID = "core-smoke-server";
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ previousModManager = setStatic("modManager", new ModManager());
+ previousStarted = setStatic("isStarted", false);
+
+ threadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", threadManager);
+
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 2);
+ globalConnectionManager =
+ new ServerGlobalConnectionManager(roomManager);
+ ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
+ handlerManager.registerInternalHandler(roomManager, globalConnectionManager);
+ server = new CoreRoomGameServer(0, handlerManager, globalConnectionManager);
+ }
+
+ @AfterEach
+ void stopTestRuntime() throws Exception {
+ if (clientChannel != null) {
+ clientChannel.close().syncUninterruptibly();
+ }
+ if (clientGroup != null) {
+ clientGroup.shutdownGracefully().syncUninterruptibly();
+ }
+ if (server != null) {
+ server.stopServer();
+ }
+ if (serverThread != null) {
+ serverThread.join(5000);
+ }
+ threadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ setStatic("modManager", previousModManager);
+ setStatic("isStarted", previousStarted);
+ }
+
+ @Test
+ void startsCoreListenerAndCompletesPreRegistrationHandshake() throws Exception {
+ List received = new ArrayList<>();
+ CountDownLatch handshakePackets = new CountDownLatch(2);
+
+ serverThread = new Thread(() -> {
+ try {
+ server.action(System.currentTimeMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "core-server-smoke");
+ serverThread.start();
+
+ assertTrue(server.awaitStarted(5, TimeUnit.SECONDS),
+ () -> "core server failed to start: " + server.getStartFailure());
+ assertTrue(server.isRunning());
+ assertTrue(server.getBoundPort() > 0);
+
+ clientGroup = new NioEventLoopGroup(1);
+ clientChannel = new Bootstrap()
+ .group(clientGroup)
+ .channel(NioSocketChannel.class)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("collector", new SimpleChannelInboundHandler() {
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
+ Packet packet) {
+ received.add(packet);
+ handshakePackets.countDown();
+ }
+ });
+ }
+ })
+ .connect("127.0.0.1", server.getBoundPort())
+ .sync()
+ .channel();
+
+ clientChannel.writeAndFlush(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]))
+ .sync();
+
+ assertTrue(handshakePackets.await(5, TimeUnit.SECONDS));
+ assertEquals(2, received.size());
+ assertEquals(PacketType.REGISTER_CONNECTION, received.get(0).type);
+ assertEquals(PacketType.SEND_CHAT, received.get(1).type);
+ assertNotNull(received.get(0).bytes);
+ assertNotNull(received.get(1).bytes);
+ }
+
+ @Test
+ void completesTcpPlayerLifecycleChatAndDisconnect() throws Exception {
+ List received = new CopyOnWriteArrayList<>();
+ CountDownLatch handshakePackets = new CountDownLatch(2);
+
+ serverThread = new Thread(() -> {
+ try {
+ server.action(System.currentTimeMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "core-server-lifecycle");
+ serverThread.start();
+
+ assertTrue(server.awaitStarted(5, TimeUnit.SECONDS),
+ () -> "core server failed to start: " + server.getStartFailure());
+
+ clientGroup = new NioEventLoopGroup(1);
+ clientChannel = new Bootstrap()
+ .group(clientGroup)
+ .channel(NioSocketChannel.class)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("collector", new SimpleChannelInboundHandler() {
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
+ Packet packet) {
+ received.add(packet);
+ handshakePackets.countDown();
+ }
+ });
+ }
+ })
+ .connect("127.0.0.1", server.getBoundPort())
+ .sync()
+ .channel();
+
+ clientChannel.writeAndFlush(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]))
+ .sync();
+ assertTrue(handshakePackets.await(5, TimeUnit.SECONDS));
+
+ clientChannel.writeAndFlush(playerInfoPacket("Alice", "tcp-player"))
+ .sync();
+ awaitCondition(() -> globalConnectionManager.size() == 1
+ && roomManager.getRoom(0).connectionManager.size() == 1,
+ 5, TimeUnit.SECONDS);
+
+ assertEquals(1, roomManager.getRoom(0).playerManager.getPlayerCount());
+ assertEquals("Alice", globalConnectionManager.getConnections().get(0).player.name);
+ awaitCondition(() -> received.stream().anyMatch(packet ->
+ packet.type == PacketType.TEAM_LIST),
+ 5, TimeUnit.SECONDS);
+ assertTrue(received.stream().anyMatch(packet -> packet.type == PacketType.SERVER_INFO));
+ assertTrue(received.stream().anyMatch(packet -> packet.type == PacketType.TEAM_LIST));
+
+ clientChannel.writeAndFlush(chatPacket("hello over tcp")).sync();
+ awaitCondition(() -> received.stream().anyMatch(packet ->
+ packet.type == PacketType.SEND_CHAT),
+ 5, TimeUnit.SECONDS);
+
+ clientChannel.writeAndFlush(disconnectPacket("client requested disconnect")).sync();
+ awaitCondition(() -> globalConnectionManager.size() == 0
+ && roomManager.getRoom(0).connectionManager.size() == 0,
+ 5, TimeUnit.SECONDS);
+
+ assertEquals(0, roomManager.getRoom(0).playerManager.getPlayerCount());
+ assertNull(globalConnectionManager.getPlayerByUUID("tcp-player"));
+ }
+
+ @Test
+ void basePluginEmitsJoinMessageForCoreRoomPlayer() {
+ cn.rukkit.network.core.handler.ServerConnectionHandler handler =
+ new cn.rukkit.network.core.handler.ServerConnectionHandler(
+ new ServerPacketHandlerManager());
+ io.netty.channel.embedded.EmbeddedChannel channel =
+ new io.netty.channel.embedded.EmbeddedChannel(handler);
+ ServerRoom room = roomManager.getRoom(0);
+ cn.rukkit.network.room.ServerRoomConnection connection =
+ new cn.rukkit.network.room.ServerRoomConnection(handler, room);
+ NetworkPlayer player = new NetworkPlayer(connection);
+ player.name = "CoreAlice";
+ connection.player = player;
+ room.connectionManager.add(connection);
+
+ new BasePlugin().onPlayerJoinTip(new PlayerJoinEvent(player));
+
+ Packet message = channel.readOutbound();
+ assertNotNull(message);
+ assertEquals(PacketType.SEND_CHAT, message.type);
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ void roomIsGamingImmediatelyAfterStartAndStartIsIdempotent()
+ throws ReflectiveOperationException {
+ ServerRoom room = roomManager.getRoom(0);
+
+ room.startGame();
+ try {
+ assertTrue(room.isGaming());
+ Field taskField = ServerRoom.class.getDeclaredField("gameTaskFuture");
+ taskField.setAccessible(true);
+ Object firstTask = taskField.get(room);
+
+ room.startGame();
+
+ assertSame(firstTask, taskField.get(room));
+ } finally {
+ room.stopGame();
+ }
+ }
+
+ @Test
+ void reconnectsOverTcpAndReusesDisconnectedPlayer() throws Exception {
+ TestServerRoom reconnectRoom = new TestServerRoom(0);
+ roomManager.roomList.set(0, reconnectRoom);
+
+ List firstReceived = new CopyOnWriteArrayList<>();
+ CountDownLatch firstHandshake = new CountDownLatch(2);
+ serverThread = new Thread(() -> {
+ try {
+ server.action(System.currentTimeMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "core-server-reconnect");
+ serverThread.start();
+ assertTrue(server.awaitStarted(5, TimeUnit.SECONDS),
+ () -> "core server failed to start: " + server.getStartFailure());
+
+ clientGroup = new NioEventLoopGroup(1);
+ clientChannel = new Bootstrap()
+ .group(clientGroup)
+ .channel(NioSocketChannel.class)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("collector", new SimpleChannelInboundHandler() {
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
+ Packet packet) {
+ firstReceived.add(packet);
+ firstHandshake.countDown();
+ }
+ });
+ }
+ })
+ .connect("127.0.0.1", server.getBoundPort())
+ .sync()
+ .channel();
+ clientChannel.writeAndFlush(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]))
+ .sync();
+ assertTrue(firstHandshake.await(5, TimeUnit.SECONDS));
+ clientChannel.writeAndFlush(playerInfoPacket("Alice", "tcp-reconnect"))
+ .sync();
+ awaitCondition(() -> globalConnectionManager.size() == 1
+ && reconnectRoom.connectionManager.size() == 1,
+ 5, TimeUnit.SECONDS);
+
+ NetworkPlayer originalPlayer = globalConnectionManager.getConnections().get(0).player;
+ reconnectRoom.currentStep = 10;
+ clientChannel.close().syncUninterruptibly();
+ clientChannel = null;
+ awaitCondition(() -> globalConnectionManager.size() == 0
+ && reconnectRoom.connectionManager.size() == 0
+ && originalPlayer.isDisconnected,
+ 5, TimeUnit.SECONDS);
+
+ List secondReceived = new CopyOnWriteArrayList<>();
+ CountDownLatch secondHandshake = new CountDownLatch(2);
+ clientChannel = new Bootstrap()
+ .group(clientGroup)
+ .channel(NioSocketChannel.class)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("collector", new SimpleChannelInboundHandler() {
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
+ Packet packet) {
+ secondReceived.add(packet);
+ secondHandshake.countDown();
+ }
+ });
+ }
+ })
+ .connect("127.0.0.1", server.getBoundPort())
+ .sync()
+ .channel();
+ clientChannel.writeAndFlush(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]))
+ .sync();
+ assertTrue(secondHandshake.await(5, TimeUnit.SECONDS));
+ clientChannel.writeAndFlush(playerInfoPacket("Alice-Reconnected", "tcp-reconnect"))
+ .sync();
+
+ awaitCondition(() -> globalConnectionManager.size() == 1
+ && reconnectRoom.connectionManager.size() == 1,
+ 5, TimeUnit.SECONDS);
+ ServerRoomConnection reconnected = reconnectRoom.connectionManager.getConnections().get(0);
+ assertSame(originalPlayer, reconnected.player);
+ assertEquals("Alice-Reconnected", reconnected.player.name);
+ assertFalse(reconnected.player.isDisconnected);
+ assertEquals(ConnectionState.IN_GAME,
+ reconnected.handler.getState());
+ awaitCondition(() -> secondReceived.stream().anyMatch(
+ packet -> packet.type == PacketType.START_GAME),
+ 5, TimeUnit.SECONDS);
+ assertTrue(secondReceived.stream().anyMatch(packet -> packet.type == PacketType.START_GAME));
+ }
+
+ @Test
+ void routesTwoPlayersToIndependentRoomsAndStartsGamesIndependently() throws Exception {
+ Rukkit.getConfig().singlePlayerMode = true;
+ serverThread = new Thread(() -> {
+ try {
+ server.action(System.currentTimeMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "core-server-multi-room");
+ serverThread.start();
+ assertTrue(server.awaitStarted(5, TimeUnit.SECONDS),
+ () -> "core server failed to start: " + server.getStartFailure());
+
+ clientGroup = new NioEventLoopGroup(2);
+ ClientFixture first = connectClient("Alice", "multi-room-first");
+ clientChannel = first.channel;
+ ClientFixture second = connectClient("Bob", "multi-room-second");
+
+ awaitCondition(() -> globalConnectionManager.getConnections().size() == 2
+ && roomManager.getRoom(0).connectionManager.getConnections().size() == 1
+ && roomManager.getRoom(1).connectionManager.getConnections().size() == 1,
+ 5, TimeUnit.SECONDS);
+
+ ServerRoomConnection firstConnection = findConnection("multi-room-first");
+ ServerRoomConnection secondConnection = findConnection("multi-room-second");
+ assertNotNull(firstConnection);
+ assertNotNull(secondConnection);
+ assertEquals(0, firstConnection.currectRoom.roomId);
+ assertEquals(1, secondConnection.currectRoom.roomId);
+ assertEquals(1, roomManager.getRoom(0).playerManager.getPlayerCount());
+ assertEquals(1, roomManager.getRoom(1).playerManager.getPlayerCount());
+
+ roomManager.getRoom(0).startGame();
+ awaitCondition(() -> hasPacket(first.received, PacketType.START_GAME),
+ 5, TimeUnit.SECONDS);
+ assertFalse(hasPacket(second.received, PacketType.START_GAME));
+
+ roomManager.getRoom(1).startGame();
+ awaitCondition(() -> hasPacket(second.received, PacketType.START_GAME),
+ 5, TimeUnit.SECONDS);
+
+ roomManager.getRoom(0).stopGame();
+ roomManager.getRoom(1).stopGame();
+ second.channel.close().syncUninterruptibly();
+ }
+
+ private ClientFixture connectClient(String name, String uuid) throws Exception {
+ List received = new CopyOnWriteArrayList<>();
+ CountDownLatch handshakePackets = new CountDownLatch(2);
+ Channel channel = new Bootstrap()
+ .group(clientGroup)
+ .channel(NioSocketChannel.class)
+ .handler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ channel.pipeline()
+ .addLast("packet-decoder", new PacketDecoder())
+ .addLast("packet-encoder", new PacketEncoder())
+ .addLast("collector", new SimpleChannelInboundHandler() {
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
+ Packet packet) {
+ received.add(packet);
+ handshakePackets.countDown();
+ }
+ });
+ }
+ })
+ .connect("127.0.0.1", server.getBoundPort())
+ .sync()
+ .channel();
+ channel.writeAndFlush(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]))
+ .sync();
+ assertTrue(handshakePackets.await(5, TimeUnit.SECONDS));
+ channel.writeAndFlush(playerInfoPacket(name, uuid)).sync();
+ return new ClientFixture(channel, received);
+ }
+
+ private ServerRoomConnection findConnection(String uuid) {
+ return globalConnectionManager.getConnections().stream()
+ .filter(connection -> uuid.equals(connection.player.uuid))
+ .findFirst()
+ .orElse(null);
+ }
+
+ private static boolean hasPacket(List packets, int packetType) {
+ return packets.stream().anyMatch(packet -> packet.type == packetType);
+ }
+
+ private static Packet playerInfoPacket(String name, String uuid) throws Exception {
+ cn.rukkit.network.io.GameOutputStream output =
+ new cn.rukkit.network.io.GameOutputStream();
+ output.writeString("com.corrodinggames.rts");
+ output.writeInt(1);
+ output.writeInt(176);
+ output.writeInt(176);
+ output.writeString(name);
+ output.writeByte(0);
+ output.writeString("");
+ output.writeString(uuid);
+ output.writeInt(0);
+ output.writeString("");
+ return output.createPacket(PacketType.PLAYER_INFO);
+ }
+
+ private static Packet chatPacket(String message) throws Exception {
+ cn.rukkit.network.io.GameOutputStream output =
+ new cn.rukkit.network.io.GameOutputStream();
+ output.writeString(message);
+ return output.createPacket(PacketType.ADD_CHAT);
+ }
+
+ private static Packet disconnectPacket(String reason) throws Exception {
+ cn.rukkit.network.io.GameOutputStream output =
+ new cn.rukkit.network.io.GameOutputStream();
+ output.writeString(reason);
+ return output.createPacket(PacketType.DISCONNECT);
+ }
+
+ private static void awaitCondition(Condition condition, long timeout, TimeUnit unit)
+ throws Exception {
+ long deadline = System.nanoTime() + unit.toNanos(timeout);
+ while (!condition.matches()) {
+ if (System.nanoTime() >= deadline) {
+ throw new AssertionError("condition was not met within " + timeout + " " + unit);
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ @FunctionalInterface
+ private interface Condition {
+ boolean matches();
+ }
+
+ private static Object setStatic(String name, Object value)
+ throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private static final class TestServerRoom extends ServerRoom {
+ private TestServerRoom(int id) {
+ super(id);
+ }
+
+ @Override
+ public void syncGame() {
+ }
+ }
+
+ private record ClientFixture(Channel channel, List received) {
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandlerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandlerBehaviorTest.java
new file mode 100644
index 0000000..469e5bb
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerAddGameCommandHandlerBehaviorTest.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.event.EventHandler;
+import cn.rukkit.event.EventListener;
+import cn.rukkit.event.EventListenerContainer;
+import cn.rukkit.event.action.BuildEvent;
+import cn.rukkit.game.GameActions;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerAddGameCommandHandlerBehaviorTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+ private ServerRoomManager roomManager;
+ private ServerGlobalConnectionManager globalConnectionManager;
+
+ @BeforeEach
+ void installTestRuntime() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 1;
+ config.maxPlayer = 2;
+ config.useCommandQuere = false;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 1);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+ BuildEvent.getListenerList().clear();
+ }
+
+ @AfterEach
+ void restoreTestRuntime() throws ReflectiveOperationException {
+ BuildEvent.getListenerList().clear();
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void rewritesMinimalCommandAndBroadcastsItAsTick() throws Exception {
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(addGameCommandPacket(minimalCommand()));
+
+ Packet tick = fixture.channel.readOutbound();
+ assertNotNull(tick);
+ assertEquals(PacketType.TICK, tick.type);
+
+ GameInputStream output = new GameInputStream(tick);
+ assertEquals(0, output.readInt());
+ assertEquals(1, output.readInt());
+ assertEquals("c", output.readString());
+ byte[] rewritten = output.readStreamBytes();
+ assertTrue(rewritten.length > 0);
+
+ GameInputStream command = new GameInputStream(rewritten);
+ assertEquals(0, command.readByte());
+ assertFalse(command.readBoolean());
+ assertFalse(command.readBoolean());
+ assertFalse(command.readBoolean());
+ assertEquals(0, command.readInt());
+ assertEquals(0, command.readInt());
+ assertFalse(command.readBoolean());
+ assertFalse(command.readBoolean());
+ assertEquals(0, command.readInt());
+ assertTrue(command.readBoolean());
+ assertEquals(fixture.connection.player.playerIndex, command.readByte());
+ assertFalse(command.readBoolean());
+ assertEquals(-1L, command.readLong());
+ assertEquals("-1", command.readString());
+ assertFalse(command.readBoolean());
+ assertEquals(0, command.readShort());
+ assertFalse(command.readBoolean());
+ assertEquals(0, command.readInt());
+ assertFalse(command.readBoolean());
+ }
+
+ @Test
+ void rebuildsSharedControlMaskFromCurrentRoomState() throws Exception {
+ ConnectionFixture fixture = newConnection();
+ fixture.connection.player.isSharingControl = true;
+
+ fixture.channel.writeInbound(addGameCommandPacket(minimalCommand()));
+
+ Packet tickPacket = fixture.channel.readOutbound();
+ GameInputStream tick = new GameInputStream(tickPacket);
+ tick.readInt();
+ tick.readInt();
+ tick.readString();
+ GameInputStream command = new GameInputStream(tick.readStreamBytes());
+ command.readByte();
+ command.readBoolean();
+ command.readBoolean();
+ command.readBoolean();
+ command.readInt();
+ command.readInt();
+ command.readBoolean();
+ command.readBoolean();
+ command.readInt();
+ command.readBoolean();
+ command.readByte();
+ command.readBoolean();
+ command.readLong();
+ command.readString();
+ command.readBoolean();
+ assertEquals(1, command.readShort());
+ }
+
+ @Test
+ void stripsSystemActionSubmittedByClient() throws Exception {
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(addGameCommandPacket(systemActionCommand()));
+
+ Packet tickPacket = fixture.channel.readOutbound();
+ GameInputStream tick = new GameInputStream(tickPacket);
+ tick.readInt();
+ tick.readInt();
+ tick.readString();
+ GameInputStream command = new GameInputStream(tick.readStreamBytes());
+ command.readByte();
+ command.readBoolean();
+ command.readBoolean();
+ command.readBoolean();
+ command.readInt();
+ command.readInt();
+ command.readBoolean();
+ command.readBoolean();
+ command.readInt();
+ command.readBoolean();
+ command.readBoolean();
+ command.readByte();
+ command.readLong();
+ command.readString();
+ command.readBoolean();
+ command.readShort();
+ assertFalse(command.readBoolean());
+ assertEquals(0, command.readInt());
+ assertFalse(command.readBoolean());
+ }
+
+ @Test
+ void publishesBuildEventBeforeBroadcastingCommand() throws Exception {
+ BuildProbe probe = new BuildProbe();
+ Method method = BuildProbe.class.getDeclaredMethod("onBuild", BuildEvent.class);
+ BuildEvent.getListenerList().registerListener(
+ new EventListenerContainer(null, method, probe));
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(addGameCommandPacket(buildCommand()));
+
+ assertTrue(probe.called);
+ assertEquals(fixture.connection.player, probe.player);
+ assertEquals(1.5f, probe.x);
+ assertEquals(2.5f, probe.y);
+ assertEquals(99L, probe.unitId);
+ assertEquals("custom-unit", probe.unitName);
+ assertNotNull(fixture.channel.readOutbound());
+ }
+
+ @Test
+ void addGameCommandOnlyAcceptsInGameConnections() {
+ assertEquals(List.of(ConnectionState.IN_GAME),
+ new ServerAddGameCommandHandler().getAllowedStates());
+ }
+
+ private ConnectionFixture newConnection() {
+ ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
+ handlerManager.registerInternalHandler(roomManager, globalConnectionManager);
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ handlerManager, globalConnectionManager::discard);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = roomManager.getRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new cn.rukkit.game.NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "add-command-handler-test";
+ room.connectionManager.add(connection);
+ globalConnectionManager.add(connection);
+ handler.setConn(connection);
+ handler.setState(ConnectionState.IN_GAME);
+ return new ConnectionFixture(handler, channel, room, connection);
+ }
+
+ private static Packet addGameCommandPacket(byte[] command) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.startBlock("c", false);
+ output.write(command);
+ output.endBlock();
+ return output.createPacket(PacketType.ADD_GAMECOMMAND);
+ }
+
+ private static byte[] minimalCommand() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeBoolean(false);
+ writeCommandTail(output);
+ return output.createPacket(PacketType.TICK).bytes;
+ }
+
+ private static byte[] systemActionCommand() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeBoolean(false);
+ writeCommandTail(output, true);
+ return output.createPacket(PacketType.TICK).bytes;
+ }
+
+ private static byte[] buildCommand() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeBoolean(true);
+ output.writeEnum(GameActions.BUILD);
+ output.writeInt(-2);
+ output.writeString("custom-unit");
+ output.writeFloat(1.5f);
+ output.writeFloat(2.5f);
+ output.writeLong(99L);
+ output.writeByte(0);
+ output.writeFloat(0.0f);
+ output.writeFloat(0.0f);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ writeCommandTail(output);
+ return output.createPacket(PacketType.TICK).bytes;
+ }
+
+ private static void writeCommandTail(GameOutputStream output) throws IOException {
+ writeCommandTail(output, false);
+ }
+
+ private static void writeCommandTail(GameOutputStream output, boolean systemAction)
+ throws IOException {
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeInt(0);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.writeLong(-1L);
+ output.writeString("-1");
+ output.writeBoolean(false);
+ output.writeShort((short) 0);
+ output.writeBoolean(systemAction);
+ if (systemAction) {
+ output.writeByte(7);
+ output.writeFloat(1.0f);
+ output.writeFloat(2.0f);
+ output.writeInt(3);
+ }
+ output.writeInt(0);
+ output.writeBoolean(false);
+ }
+
+ private static Object setStatic(String name, Object value)
+ throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ConnectionFixture(ServerConnectionHandler handler,
+ EmbeddedChannel channel,
+ ServerRoom room,
+ ServerRoomConnection connection) {
+ }
+
+ private static final class BuildProbe implements EventListener {
+ private boolean called;
+ private cn.rukkit.game.NetworkPlayer player;
+ private float x;
+ private float y;
+ private long unitId;
+ private String unitName;
+
+ @EventHandler
+ public void onBuild(BuildEvent event) {
+ called = true;
+ player = event.getPlayer();
+ x = event.getTargetX();
+ y = event.getTargetY();
+ unitId = event.getFromUnitId();
+ unitName = event.getTargetUnitName();
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerLowRiskHandlerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerLowRiskHandlerBehaviorTest.java
new file mode 100644
index 0000000..b752af2
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerLowRiskHandlerBehaviorTest.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.event.EventHandler;
+import cn.rukkit.event.EventListener;
+import cn.rukkit.event.EventListenerContainer;
+import cn.rukkit.event.server.ServerQuestionRespondEvent;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerLowRiskHandlerBehaviorTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+ private ServerRoomManager roomManager;
+ private ServerGlobalConnectionManager globalConnectionManager;
+
+ @BeforeEach
+ void installTestRuntime() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 1;
+ config.maxPlayer = 2;
+ config.isDebug = false;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 1);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+ ServerQuestionRespondEvent.getListenerList().clear();
+ }
+
+ @AfterEach
+ void restoreTestRuntime() throws ReflectiveOperationException {
+ ServerQuestionRespondEvent.getListenerList().clear();
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void disconnectUsesClientReasonAndCleansUpCoreRegistries() throws Exception {
+ ConnectionFixture fixture = newConnection(ConnectionState.IN_ROOM);
+
+ fixture.channel.writeInbound(stringPacket(PacketType.DISCONNECT, "client left"));
+
+ assertEquals(ConnectionState.DISCONNECTED, fixture.handler.getState());
+ assertEquals("client left", fixture.handler.getDisconnectReason());
+ assertFalse(fixture.channel.isOpen());
+ assertEquals(0, globalConnectionManager.size());
+ assertEquals(0, fixture.room.connectionManager.size());
+ }
+
+ @Test
+ void questionResponsePublishesTheMasterEventPayload() throws Exception {
+ QuestionProbe probe = new QuestionProbe();
+ Method method = QuestionProbe.class.getDeclaredMethod(
+ "onQuestion", ServerQuestionRespondEvent.class);
+ ServerQuestionRespondEvent.getListenerList().registerListener(
+ new EventListenerContainer(null, method, probe));
+ ConnectionFixture fixture = newConnection(ConnectionState.IN_ROOM);
+
+ fixture.channel.writeInbound(questionResponsePacket(42, "yes"));
+
+ assertSame(fixture.connection.player, probe.player);
+ assertEquals(42, probe.questionId);
+ assertEquals("yes", probe.response);
+ }
+
+ @Test
+ void randyBroadcastMatchesMasterMessage() throws Exception {
+ ConnectionFixture fixture = newConnection(ConnectionState.IN_GAME);
+
+ fixture.channel.writeInbound(new Packet(PacketType.READY, new byte[0]));
+
+ Packet message = fixture.channel.readOutbound();
+ assertNotNull(message);
+ assertEquals(PacketType.SEND_CHAT, message.type);
+ assertEquals("Player 'Alice' is randy.", new GameInputStream(message).readString());
+ }
+
+ @Test
+ void lowRiskHandlersKeepMasterStateRestrictions() {
+ assertEquals(List.of(ConnectionState.PRE_REGISTERED,
+ ConnectionState.IN_ROOM,
+ ConnectionState.IN_GAME),
+ new ServerDisconnectHandler().getAllowedStates());
+ assertEquals(List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME),
+ new ServerQuestionResponseHandler().getAllowedStates());
+ assertEquals(List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME),
+ new ServerRandyHandler().getAllowedStates());
+ }
+
+ private ConnectionFixture newConnection(ConnectionState state) {
+ ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
+ handlerManager.registerInternalHandler(roomManager, globalConnectionManager);
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ handlerManager, globalConnectionManager::discard);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = roomManager.getRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "low-risk-handler-test";
+ room.connectionManager.add(connection);
+ globalConnectionManager.add(connection);
+ handler.setConn(connection);
+ handler.setState(state);
+ return new ConnectionFixture(handler, channel, room, connection);
+ }
+
+ private static Packet stringPacket(int type, String value) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString(value);
+ return output.createPacket(type);
+ }
+
+ private static Packet questionResponsePacket(int questionId, String response)
+ throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(questionId);
+ output.writeString(response);
+ return output.createPacket(PacketType.QUESTION_RESPONSE);
+ }
+
+ private static Object setStatic(String name, Object value)
+ throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ConnectionFixture(ServerConnectionHandler handler,
+ EmbeddedChannel channel,
+ ServerRoom room,
+ ServerRoomConnection connection) {
+ }
+
+ private static final class QuestionProbe implements EventListener {
+ private NetworkPlayer player;
+ private int questionId;
+ private String response;
+
+ @EventHandler
+ public void onQuestion(ServerQuestionRespondEvent event) {
+ player = event.getPlayer();
+ questionId = event.getQid();
+ response = event.getRespondMessage();
+ }
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
index 3a787ad..4f433b2 100644
--- a/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerPlayerInfoHandlerBehaviorTest.java
@@ -17,6 +17,7 @@
import cn.rukkit.network.ConnectionState;
import cn.rukkit.network.core.packet.Packet;
import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.core.packet.UniversalPacket;
import cn.rukkit.network.io.GameOutputStream;
import cn.rukkit.network.room.ServerGlobalConnectionManager;
import cn.rukkit.network.room.ServerRoom;
@@ -29,11 +30,13 @@
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -112,6 +115,84 @@ void registersFirstPlayerIntoNewRoomAndGlobalRegistry() throws Exception {
assertTrue(packetTypes.contains(PacketType.SEND_CHAT));
}
+ @Test
+ void completesPreRegistrationBeforePlayerInfo() throws Exception {
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(new Packet(PacketType.PREREGISTER_CONNECTION, new byte[0]));
+
+ assertEquals(ConnectionState.PRE_REGISTERED, fixture.handler.getState());
+ Packet registrationPacket = fixture.channel.readOutbound();
+ Packet promptPacket = fixture.channel.readOutbound();
+ assertEquals(PacketType.REGISTER_CONNECTION, registrationPacket.type);
+ assertEquals(PacketType.SEND_CHAT, promptPacket.type);
+
+ fixture.channel.writeInbound(playerInfoPacket("Alice", "uuid-handshake"));
+
+ assertEquals(ConnectionState.IN_ROOM, fixture.handler.getState());
+ assertNotNull(fixture.handler.getConn());
+ }
+
+ @Test
+ void updatesPlayerPingInRoomAndGameStates() throws Exception {
+ ConnectionFixture fixture = registerPlayer("Alice", "uuid-heartbeat");
+ ServerRoomConnection connection = fixture.handler.getConn();
+
+ for (ConnectionState state : List.of(ConnectionState.IN_ROOM, ConnectionState.IN_GAME)) {
+ fixture.handler.setState(state);
+ connection.pingTime = System.currentTimeMillis() - 25;
+
+ fixture.channel.writeInbound(new Packet(PacketType.HEART_BEAT_RESPONSE, new byte[0]));
+
+ assertTrue(connection.player.ping >= 0);
+ assertTrue(connection.player.ping < 1000);
+ assertEquals(state, fixture.handler.getState());
+ }
+ }
+
+ @Test
+ void broadcastsOrdinaryChatThroughMigratedRoom() throws Exception {
+ ConnectionFixture fixture = registerPlayer("Alice", "uuid-chat");
+ drainPacketTypes(fixture.channel);
+
+ fixture.channel.writeInbound(chatPacket("hello"));
+
+ Packet actual = fixture.channel.readOutbound();
+ Packet expected = UniversalPacket.chat("Alice", "hello", fixture.handler.getConn().player.playerIndex);
+ assertNotNull(actual);
+ assertEquals(PacketType.SEND_CHAT, actual.type);
+ assertArrayEquals(expected.bytes, actual.bytes);
+ }
+
+ @Test
+ void forwardsChatCommandToInjectedCoreDispatcher() throws Exception {
+ AtomicReference receivedConnection = new AtomicReference<>();
+ AtomicReference receivedCommand = new AtomicReference<>();
+ ConnectionFixture fixture = newConnection((connection, command) -> {
+ receivedConnection.set(connection);
+ receivedCommand.set(command);
+ });
+ fixture.handler.setState(ConnectionState.PRE_REGISTERED);
+ fixture.channel.writeInbound(playerInfoPacket("Alice", "uuid-command"));
+ drainPacketTypes(fixture.channel);
+
+ fixture.channel.writeInbound(chatPacket(".version now"));
+
+ assertSame(fixture.handler.getConn(), receivedConnection.get());
+ assertEquals("version now", receivedCommand.get());
+ }
+
+ @Test
+ void ignoresHeartbeatWithoutARegisteredConnection() {
+ ConnectionFixture fixture = newConnection();
+ fixture.handler.setState(ConnectionState.IN_ROOM);
+
+ fixture.channel.writeInbound(new Packet(PacketType.HEART_BEAT_RESPONSE, new byte[0]));
+
+ assertTrue(fixture.channel.isOpen());
+ assertEquals(ConnectionState.IN_ROOM, fixture.handler.getState());
+ }
+
@Test
void rejectsDuplicatePlayerWhileRoomIsNotGaming() throws Exception {
ConnectionFixture first = registerPlayer("Alice", "uuid-duplicate");
@@ -127,6 +208,44 @@ void rejectsDuplicatePlayerWhileRoomIsNotGaming() throws Exception {
assertTrue(drainPacketTypes(duplicate.channel).contains(PacketType.KICK));
}
+ @Test
+ void rejectsNewPlayerWhenSelectedRoomIsAlreadyGaming() throws Exception {
+ TestServerRoom room = new TestServerRoom(0);
+ room.currentStep = 10;
+ roomManager = new FixedRoomManager(room);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+
+ ConnectionFixture fixture = newConnection();
+ fixture.handler.setState(ConnectionState.PRE_REGISTERED);
+ fixture.channel.writeInbound(playerInfoPacket("Alice", "uuid-game-started"));
+
+ assertNull(fixture.handler.getConn());
+ assertEquals(0, room.playerManager.getPlayerCount());
+ assertEquals(0, room.connectionManager.size());
+ assertEquals(0, globalConnectionManager.size());
+ assertTrue(drainPacketTypes(fixture.channel).contains(PacketType.KICK));
+ }
+
+ @Test
+ void doesNotPublishConnectionWhenPlayerSlotsAreFull() throws Exception {
+ Rukkit.getConfig().maxPlayer = 1;
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 1);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+
+ ConnectionFixture first = registerPlayer("Alice", "uuid-capacity-first");
+ ConnectionFixture spare = newConnection();
+ ServerRoomConnection extra = new ServerRoomConnection(spare.handler, first.room);
+ extra.player = new NetworkPlayer(extra);
+ extra.player.name = "Bob";
+ extra.player.uuid = "uuid-capacity-second";
+
+ first.room.connectionManager.add(extra);
+
+ assertEquals(1, first.room.playerManager.getPlayerCount());
+ assertEquals(1, first.room.connectionManager.getConnections().size());
+ assertEquals(1, first.room.connectionManager.size());
+ }
+
@Test
void reconnectsDisconnectedPlayerToTheNewConnectionInGame() throws Exception {
TestServerRoom room = new TestServerRoom(0);
@@ -154,6 +273,48 @@ void reconnectsDisconnectedPlayerToTheNewConnectionInGame() throws Exception {
assertEquals(ConnectionState.IN_GAME, reconnect.handler.getState());
}
+ @Test
+ void oldConnectionClosingAfterReconnectDoesNotDisconnectReplacement() throws Exception {
+ TestServerRoom room = new TestServerRoom(0);
+ roomManager.roomList.set(0, room);
+ ConnectionFixture first = registerPlayer("Alice", "uuid-overlapping-reconnect");
+ NetworkPlayer player = first.handler.getConn().player;
+
+ room.currentStep = 10;
+ ConnectionFixture reconnect = newConnection();
+ reconnect.handler.setState(ConnectionState.PRE_REGISTERED);
+ reconnect.channel.writeInbound(
+ playerInfoPacket("Alice-Reconnected", "uuid-overlapping-reconnect"));
+
+ assertSame(player, reconnect.handler.getConn().player);
+ assertEquals(1, room.connectionManager.getConnections().size());
+ assertEquals(1, globalConnectionManager.getConnections().size());
+
+ first.channel.close();
+
+ assertSame(reconnect.handler.getConn(), player.getServerConnection());
+ assertFalse(player.isDisconnected);
+ assertEquals(1, room.connectionManager.getConnections().size(),
+ room.connectionManager.getConnections().toString());
+ assertEquals(1, globalConnectionManager.getConnections().size());
+ }
+
+ @Test
+ void transfersAdminToLivePlayerWhenAdminLeavesDuringGame() throws Exception {
+ ConnectionFixture admin = registerPlayer("Admin", "uuid-admin");
+ ConnectionFixture survivor = registerPlayer("Survivor", "uuid-survivor");
+ admin.handler.getConn().player.isAdmin = true;
+ survivor.handler.getConn().player.isAdmin = false;
+ admin.room.currentStep = 10;
+
+ admin.channel.close();
+
+ assertFalse(admin.handler.getConn().player.isAdmin);
+ assertTrue(survivor.handler.getConn().player.isAdmin);
+ assertEquals(1, admin.room.connectionManager.getConnections().size());
+ assertEquals(1, globalConnectionManager.getConnections().size());
+ }
+
private ConnectionFixture registerPlayer(String name, String uuid) throws Exception {
ConnectionFixture fixture = newConnection();
fixture.handler.setState(ConnectionState.PRE_REGISTERED);
@@ -162,8 +323,12 @@ private ConnectionFixture registerPlayer(String name, String uuid) throws Except
}
private ConnectionFixture newConnection() {
+ return newConnection(null);
+ }
+
+ private ConnectionFixture newConnection(ServerChatCommandDispatcher commandDispatcher) {
ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
- handlerManager.register(new ServerPlayerInfoHandler(roomManager, globalConnectionManager));
+ handlerManager.registerInternalHandler(roomManager, globalConnectionManager, commandDispatcher);
ServerConnectionHandler handler = new ServerConnectionHandler(
handlerManager, globalConnectionManager::discard);
EmbeddedChannel channel = new EmbeddedChannel(handler);
@@ -187,6 +352,12 @@ private static Packet playerInfoPacket(String name, String uuid) throws IOExcept
return output.createPacket(PacketType.PLAYER_INFO);
}
+ private static Packet chatPacket(String message) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeString(message);
+ return output.createPacket(PacketType.ADD_CHAT);
+ }
+
private static List drainPacketTypes(EmbeddedChannel channel) {
List types = new ArrayList<>();
Packet packet;
@@ -209,6 +380,21 @@ private record ConnectionFixture(ServerConnectionHandler handler,
ServerRoom room) {
}
+ private static final class FixedRoomManager extends ServerRoomManager {
+ private final ServerRoom selectedRoom;
+
+ private FixedRoomManager(ServerRoom selectedRoom) {
+ super(Rukkit.getRoundConfig(), 1);
+ this.selectedRoom = selectedRoom;
+ roomList.set(0, selectedRoom);
+ }
+
+ @Override
+ public ServerRoom getAvailableRoom() {
+ return selectedRoom;
+ }
+ }
+
private static final class TestServerRoom extends ServerRoom {
private TestServerRoom(int id) {
super(id);
diff --git a/src/test/java/cn/rukkit/network/core/handler/ServerSyncHandlerBehaviorTest.java b/src/test/java/cn/rukkit/network/core/handler/ServerSyncHandlerBehaviorTest.java
new file mode 100644
index 0000000..24df63e
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/handler/ServerSyncHandlerBehaviorTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ */
+
+package cn.rukkit.network.core.handler;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.SaveData;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.io.GameOutputStream;
+import cn.rukkit.network.room.ServerGlobalConnectionManager;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.network.room.ServerRoomManager;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ServerSyncHandlerBehaviorTest {
+ private final List channels = new ArrayList<>();
+ private final List handlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private ThreadManager testThreadManager;
+ private ServerRoomManager roomManager;
+ private ServerGlobalConnectionManager globalConnectionManager;
+
+ @BeforeEach
+ void installTestRuntime() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 1;
+ config.maxPlayer = 2;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+
+ roomManager = new ServerRoomManager(Rukkit.getRoundConfig(), 1);
+ globalConnectionManager = new ServerGlobalConnectionManager(roomManager);
+ }
+
+ @AfterEach
+ void restoreTestRuntime() throws ReflectiveOperationException {
+ for (ServerConnectionHandler handler : handlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ }
+
+ @Test
+ void storesLargeSyncPayloadWithMasterTimeConversion() throws Exception {
+ ConnectionFixture fixture = newConnection();
+ byte[] save = new byte[21];
+ for (int i = 0; i < save.length; i++) {
+ save[i] = (byte) (i + 1);
+ }
+
+ fixture.channel.writeInbound(syncPacket(300, 150, save));
+
+ SaveData actual = fixture.connection.save;
+ assertNotNull(actual);
+ assertEquals(10, actual.time);
+ assertArrayEquals(save, actual.arr);
+ }
+
+ @Test
+ void ignoresSyncPayloadsAtOrBelowMasterMinimumSize() throws Exception {
+ ConnectionFixture fixture = newConnection();
+ SaveData previous = new SaveData();
+ previous.arr = new byte[]{9, 8, 7};
+ previous.time = 4;
+ fixture.connection.save = previous;
+
+ fixture.channel.writeInbound(syncPacket(300, 150, new byte[20]));
+
+ assertEquals(previous, fixture.connection.save);
+ }
+
+ @Test
+ void recordsChecksumValuesAndNotifiesRoom() throws Exception {
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(checksumResponsePacket(
+ 300, 456, new long[]{111L, 222L, 333L}));
+
+ assertEquals(456, fixture.connection.lastSyncTick);
+ assertTrue(fixture.connection.checkSumSent);
+ assertEquals(1, fixture.room.checkSumReceived.get());
+ assertEquals(111L, fixture.connection.player.checkList.get(0).getCheckData());
+ assertEquals(222L, fixture.connection.player.checkList.get(1).getCheckData());
+ assertEquals(333L, fixture.connection.player.checkList.get(2).getCheckData());
+ }
+
+ @Test
+ void requestsChecksumAgainWhenClientDoesNotSendValues() throws Exception {
+ ConnectionFixture fixture = newConnection();
+
+ fixture.channel.writeInbound(checksumResponseWithoutValues(300, 789));
+
+ Packet retry = fixture.channel.readOutbound();
+ assertNotNull(retry);
+ assertEquals(PacketType.SYNC_CHECKSUM, retry.type);
+ assertEquals(789, new GameInputStream(retry).readInt());
+ assertEquals(789, fixture.connection.lastSyncTick);
+ }
+
+ @Test
+ void syncHandlersOnlyAcceptInGameConnections() {
+ assertEquals(List.of(ConnectionState.IN_GAME),
+ new ServerSyncHandler().getAllowedStates());
+ assertEquals(List.of(ConnectionState.IN_GAME),
+ new ServerSyncChecksumResponseHandler().getAllowedStates());
+ }
+
+ private ConnectionFixture newConnection() {
+ ServerPacketHandlerManager handlerManager = new ServerPacketHandlerManager();
+ handlerManager.registerInternalHandler(roomManager, globalConnectionManager);
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ handlerManager, globalConnectionManager::discard);
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ handlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = roomManager.getRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new cn.rukkit.game.NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "sync-handler-test";
+ room.connectionManager.add(connection);
+ globalConnectionManager.add(connection);
+ handler.setConn(connection);
+ handler.setState(ConnectionState.IN_GAME);
+ return new ConnectionFixture(handler, channel, room, connection);
+ }
+
+ private static Packet syncPacket(int frame, int time, byte[] save) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(frame);
+ output.writeInt(time);
+ output.writeFloat(1.0f);
+ output.writeFloat(1.0f);
+ output.writeBoolean(false);
+ output.writeBoolean(false);
+ output.startBlock("gameSave", false);
+ output.write(save);
+ output.endBlock();
+ return output.createPacket(PacketType.SYNC);
+ }
+
+ private static Packet checksumResponsePacket(int serverTick, int clientTick,
+ long[] values) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(serverTick);
+ output.writeInt(clientTick);
+ output.writeBoolean(true);
+ output.writeLong(0L);
+ output.writeLong(0L);
+ output.startBlock("checksum", false);
+ output.writeInt(0);
+ output.writeInt(values.length);
+ for (long value : values) {
+ output.writeLong(0L);
+ output.writeLong(value);
+ }
+ output.endBlock();
+ return output.createPacket(PacketType.SYNC_CHECKSUM_RESPONSE);
+ }
+
+ private static Packet checksumResponseWithoutValues(int serverTick, int clientTick)
+ throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeByte(0);
+ output.writeInt(serverTick);
+ output.writeInt(clientTick);
+ output.writeBoolean(false);
+ return output.createPacket(PacketType.SYNC_CHECKSUM_RESPONSE);
+ }
+
+ private static Object setStatic(String name, Object value)
+ throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ConnectionFixture(ServerConnectionHandler handler,
+ EmbeddedChannel channel,
+ ServerRoom room,
+ ServerRoomConnection connection) {
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java b/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
index 9a81b10..83ef613 100644
--- a/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
+++ b/src/test/java/cn/rukkit/network/core/packet/UniversalPacketCompatibilityTest.java
@@ -9,19 +9,47 @@
package cn.rukkit.network.core.packet;
+import cn.rukkit.Rukkit;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
import cn.rukkit.game.PingType;
import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.network.io.GameInputStream;
+import java.lang.reflect.Field;
import java.io.IOException;
import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Locks down the wire representation while packet builders move packages.
*/
class UniversalPacketCompatibilityTest {
+ private Object previousConfig;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField("config");
+ field.setAccessible(true);
+ previousConfig = field.get(null);
+ RukkitConfig config = new RukkitConfig();
+ config.UUID = "test-server-uuid";
+ field.set(null, config);
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField("config");
+ field.setAccessible(true);
+ field.set(null, previousConfig);
+ }
+
@FunctionalInterface
private interface PacketFactory {
T create() throws IOException;
@@ -44,8 +72,31 @@ void gameCommandMatchesLegacyPacket() throws IOException {
() -> UniversalPacket.gameCommand(42, command));
}
+ @Test
+ void gameCommandPacketSupportsMultipleOriginalStyleBlocks() throws IOException {
+ GameCommand first = new GameCommand();
+ first.arr = new byte[] {1, 2, 3};
+ GameCommand second = new GameCommand();
+ second.arr = new byte[] {5, 8, 13};
+ List commands = List.of(first, second);
+
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.gameCommands(42, commands),
+ () -> UniversalPacket.gameCommands(42, commands));
+
+ GameInputStream input = new GameInputStream(
+ UniversalPacket.gameCommands(42, commands));
+ assertEquals(42, input.readInt());
+ assertEquals(2, input.readInt());
+ assertArrayEquals(first.arr, input.getBlockRaw("c"));
+ assertArrayEquals(second.arr, input.getBlockRaw("c"));
+ }
+
@Test
void simplePacketsMatchLegacyPacket() throws IOException {
+ assertEquivalent(
+ () -> cn.rukkit.network.packet.Packet.preRegister(),
+ () -> UniversalPacket.preRegister());
assertEquivalent(
() -> cn.rukkit.network.packet.Packet.emptyCommand(42),
() -> UniversalPacket.emptyCommand(42));
@@ -63,6 +114,18 @@ void simplePacketsMatchLegacyPacket() throws IOException {
() -> UniversalPacket.packetReturnToBattleroom());
}
+ @Test
+ void startPacketUsesTheProvidedRoomConfiguration() throws IOException {
+ RoundConfig roomConfig = new RoundConfig();
+ roomConfig.mapType = 0;
+ roomConfig.mapName = "room-specific-map";
+
+ GameInputStream input = new GameInputStream(UniversalPacket.startGame(roomConfig));
+ assertEquals(0, input.readByte());
+ assertEquals(0, input.readInt());
+ assertEquals("maps/skirmish/room-specific-map.tmx", input.readString());
+ }
+
@Test
void saveAndChecksumPacketsMatchLegacyPacket() throws IOException {
byte[] save = {0, 1, 2, 3, 8, 13, 21};
@@ -78,6 +141,22 @@ void saveAndChecksumPacketsMatchLegacyPacket() throws IOException {
() -> UniversalPacket.syncCheckSum(123));
}
+ @Test
+ void savePacketContainsTheGameSaveBlock() throws IOException {
+ byte[] save = {0, 1, 2, 3, 8, 13, 21};
+ GameInputStream input = new GameInputStream(UniversalPacket.sendSave(123, save, false));
+
+ input.readByte();
+ input.readInt();
+ input.readInt();
+ input.readFloat();
+ input.readFloat();
+ assertFalse(input.readBoolean());
+ assertFalse(input.readBoolean());
+
+ assertArrayEquals(save, input.getBlockRaw("gameSave"));
+ }
+
@Test
void gameActionPacketsMatchLegacyPacket() throws IOException {
assertEquivalent(
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
index 592b08c..4f743de 100644
--- a/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomBehaviorTest.java
@@ -12,10 +12,13 @@
import cn.rukkit.Rukkit;
import cn.rukkit.config.RoundConfig;
import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.SaveData;
import cn.rukkit.network.NetworkRoom;
import cn.rukkit.network.command.GameCommand;
+import cn.rukkit.service.ThreadManager;
import java.lang.reflect.Field;
-import java.util.LinkedList;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -27,17 +30,28 @@
class ServerRoomBehaviorTest {
private Object previousConfig;
private Object previousRound;
+ private Object previousThreadManager;
+ private Object previousDefaultSave;
+ private ThreadManager testThreadManager;
@BeforeEach
void installTestConfiguration() throws ReflectiveOperationException {
previousConfig = setStatic("config", new RukkitConfig());
previousRound = setStatic("round", new RoundConfig());
+ testThreadManager = new ThreadManager(1);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ SaveData defaultSave = new SaveData();
+ defaultSave.arr = new byte[0];
+ previousDefaultSave = setStatic("defaultSave", defaultSave);
}
@AfterEach
void restoreConfiguration() throws ReflectiveOperationException {
+ testThreadManager.shutdown();
setStatic("config", previousConfig);
setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ setStatic("defaultSave", previousDefaultSave);
}
@Test
@@ -93,10 +107,92 @@ void commandQueueModeMatchesLegacyRoom() throws ReflectiveOperationException {
assertEquals(1, queueSize(ServerRoom.class, migrated));
}
+ @Test
+ void pausedRoomDoesNotAcceptNewCommands() throws ReflectiveOperationException {
+ RukkitConfig config = (RukkitConfig) getStatic("config");
+ config.useCommandQuere = true;
+ ServerRoom room = new ServerRoom(2);
+ room.setPaused(true);
+
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {4, 5, 6};
+ room.addCommand(command);
+
+ assertEquals(0, queueSize(ServerRoom.class, room));
+ room.stopGame();
+ }
+
+ @Test
+ void stopGameClearsPendingCommands() throws ReflectiveOperationException {
+ RukkitConfig config = (RukkitConfig) getStatic("config");
+ config.useCommandQuere = true;
+ ServerRoom room = new ServerRoom(2);
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {7, 8, 9};
+ room.addCommand(command);
+
+ assertEquals(1, queueSize(ServerRoom.class, room));
+ room.stopGame(true);
+
+ assertEquals(0, queueSize(ServerRoom.class, room));
+ assertTrue(room.isPaused());
+ }
+
+ @Test
+ void syncFlushesPendingCommandsBeforeRequestingSave() throws Exception {
+ RukkitConfig config = (RukkitConfig) getStatic("config");
+ config.useCommandQuere = true;
+ ServerRoom room = new ServerRoom(2);
+ GameCommand command = new GameCommand();
+ command.arr = new byte[] {10, 11, 12};
+ room.addCommand(command);
+
+ room.syncGame();
+
+ assertEquals(0, queueSize(ServerRoom.class, room));
+ assertTrue(room.isPaused());
+ room.stopGame();
+ }
+
+ @Test
+ void syncWaitDoesNotOccupyTheOnlySharedWorkerThread() throws Exception {
+ ServerRoom room = new ServerRoom(2);
+ room.syncGame();
+
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
+ while (testThreadManager.getActiveThreadCount() == 0
+ && System.nanoTime() < deadline) {
+ Thread.yield();
+ }
+
+ Future> marker = testThreadManager.submit(() -> { });
+ marker.get(1, TimeUnit.SECONDS);
+
+ assertTrue(room.isPaused());
+ room.stopGame();
+ }
+
+ @Test
+ void checksumWaitDoesNotOccupyTheOnlySharedWorkerThread() throws Exception {
+ ServerRoom room = new ServerRoom(2);
+ room.doChecksum();
+
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
+ while (testThreadManager.getActiveThreadCount() == 0
+ && System.nanoTime() < deadline) {
+ Thread.yield();
+ }
+
+ Future> marker = testThreadManager.submit(() -> { });
+ marker.get(1, TimeUnit.SECONDS);
+
+ room.stopGame();
+ }
+
private static int queueSize(Class> roomType, Object room) throws ReflectiveOperationException {
Field queue = roomType.getDeclaredField("commandQuere");
queue.setAccessible(true);
- return ((LinkedList>) queue.get(room)).size();
+ return ((RoomCommandQueue) queue.get(room)).size();
}
private static Object getStatic(String name) throws ReflectiveOperationException {
diff --git a/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java b/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
index 769c5bc..08fb0b7 100644
--- a/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
+++ b/src/test/java/cn/rukkit/network/room/ServerRoomManagerBehaviorTest.java
@@ -19,7 +19,9 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -66,6 +68,23 @@ void selectsFirstNonGamingRoomWithCapacity() {
assertEquals(1, available.roomId);
}
+ @Test
+ void roomsOwnIndependentRoundConfigurations() {
+ ServerRoomManager manager = new ServerRoomManager(Rukkit.getRoundConfig(), 3);
+ ServerRoom first = manager.getRoom(0);
+ ServerRoom second = manager.getRoom(1);
+
+ assertNotSame(first.config, second.config);
+ assertEquals(Rukkit.getRoundConfig().mapName, first.config.mapName);
+
+ first.config.mapName = "room-one-map";
+ first.config.income = 2.0f;
+
+ assertNotEquals(first.config.mapName, second.config.mapName);
+ assertNotEquals(first.config.income, second.config.income);
+ assertEquals(Rukkit.getRoundConfig().mapName, second.config.mapName);
+ }
+
@Test
void resetRebuildsRoomsWithoutConcurrentModification() {
ServerRoomManager manager = new ServerRoomManager(Rukkit.getRoundConfig(), 3);
From 39abc13a05de5d44b2daaa3b2e35bdfe9f849bf1 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Mon, 10 Aug 2026 13:59:17 +0800
Subject: [PATCH 8/9] fix(legacy): align command and sync handlers
---
.../packet/handler/AddGameCommandHandler.java | 36 +-
.../network/packet/handler/SyncHandler.java | 4 +-
.../rukkit/plugin/internal/CommandPlugin.java | 97 ++--
...mmandPluginSimpleCommandMigrationTest.java | 515 ++++++++++++++++++
4 files changed, 588 insertions(+), 64 deletions(-)
create mode 100644 src/test/java/cn/rukkit/plugin/internal/CommandPluginSimpleCommandMigrationTest.java
diff --git a/src/main/java/cn/rukkit/network/packet/handler/AddGameCommandHandler.java b/src/main/java/cn/rukkit/network/packet/handler/AddGameCommandHandler.java
index c6200d6..8aa3072 100644
--- a/src/main/java/cn/rukkit/network/packet/handler/AddGameCommandHandler.java
+++ b/src/main/java/cn/rukkit/network/packet/handler/AddGameCommandHandler.java
@@ -153,13 +153,15 @@ public void handle(PacketContext ctx, Packet packet) throws Exception {
out.writeLong(str.readLong());
}
- if (str.readBoolean()) {
- out.writeBoolean(true);
- byte byte2 = str.readByte();
- out.writeByte(byte2);
- } else {
- out.writeBoolean(false);
+ // The field is the pre-command player. The original server binds it
+ // to the connection that submitted the command instead of trusting
+ // the player index supplied by the client.
+ boolean hasCommandPlayer = str.readBoolean();
+ if (hasCommandPlayer) {
+ str.readByte();
}
+ out.writeBoolean(true);
+ out.writeByte(connection.player.playerIndex);
float pingX = 0;
float pingY = 0;
@@ -190,21 +192,18 @@ public void handle(PacketContext ctx, Packet packet) throws Exception {
out.writeBoolean(bool7);
str.readShort();
- out.stream.writeShort(32767);
+ out.writeShort(connection.currectRoom.playerManager.getSharedControlMask());
+ // The original server never forwards a system action supplied by a
+ // client. Consume its payload to keep the stream aligned, then clear
+ // the flag before broadcasting the normal command.
if (str.readBoolean()) {
- out.writeBoolean(true);
str.readByte();
- out.writeByte(0);
- float f1 = str.readFloat();
- float f2 = str.readFloat();
- int i1 = str.readInt();
- out.writeFloat(f1);
- out.writeFloat(f2);
- out.writeInt(i1);
- } else {
- out.writeBoolean(false);
+ str.readFloat();
+ str.readFloat();
+ str.readInt();
}
+ out.writeBoolean(false);
int movementUnitCount = str.readInt();
out.writeInt(movementUnitCount);
@@ -278,13 +277,14 @@ public void handle(PacketContext ctx, Packet packet) throws Exception {
if (act != null) {
ListenerList list = (ListenerList) act.getClass().getMethod("getListenerList").invoke(null);
if (list.callListeners(act)) {
+ connection.player.markCommandActivity();
connection.sendGameCommand(cmd);
} else {
getLogger().debug("Event {} cancelled!", act);
}
} else {
+ connection.player.markCommandActivity();
connection.sendGameCommand(cmd);
}
}
}
-
diff --git a/src/main/java/cn/rukkit/network/packet/handler/SyncHandler.java b/src/main/java/cn/rukkit/network/packet/handler/SyncHandler.java
index 6c82a53..136ac76 100644
--- a/src/main/java/cn/rukkit/network/packet/handler/SyncHandler.java
+++ b/src/main/java/cn/rukkit/network/packet/handler/SyncHandler.java
@@ -37,8 +37,7 @@ public void handle(PacketContext ctx, Packet packet) throws Exception {
int frame = in.readInt();
int time = in.readInt() / 15;
getLogger().trace("sync frame={} payload: {}, {}, {}, {}", frame, in.readFloat(), in.readFloat(), in.readBoolean(), in.readBoolean());
- byte[] save = new byte[in.stream.available()];
- in.stream.read(save);
+ byte[] save = in.getBlockRaw("gameSave");
if (save.length > 20) {
SaveData data = new SaveData();
data.arr = save;
@@ -47,4 +46,3 @@ public void handle(PacketContext ctx, Packet packet) throws Exception {
}
}
}
-
diff --git a/src/main/java/cn/rukkit/plugin/internal/CommandPlugin.java b/src/main/java/cn/rukkit/plugin/internal/CommandPlugin.java
index 8adcc02..3b1bb06 100644
--- a/src/main/java/cn/rukkit/plugin/internal/CommandPlugin.java
+++ b/src/main/java/cn/rukkit/plugin/internal/CommandPlugin.java
@@ -129,11 +129,12 @@ public boolean onSend(RoomConnection con, String[] args) {
// Maps
if (type == 0) {
StringBuilder build = new StringBuilder();
+ int page = MapCommandSupport.pageIndex(args);
+ if (page < 0) return false;
if (args.length > 0) {
build.append("- Maps - Page ").append(args[0]).append(" \n");
- int page = Integer.parseInt(args[0]) - 1;
- for (int i = page * 10;i < OfficialMap.maps.length;i++) {
- if (i > page * 10 + 10) break;
+ for (int i = page * MapCommandSupport.PAGE_SIZE;
+ i < MapCommandSupport.pageEnd(page, OfficialMap.maps.length); i++) {
build.append(String.format("[%d] %s", i, OfficialMap.maps[i])).append("\n");
}
} else {
@@ -145,25 +146,17 @@ public boolean onSend(RoomConnection con, String[] args) {
con.sendServerMessage(build.toString());
} else {
if (con.player.isAdmin && args.length > 0) {
- if (args[0].startsWith("'")) {
- String mapString = args[0].split("'")[1];
- for (int i=0;i < OfficialMap.mapsName.length;i++) {
- if (OfficialMap.mapsName[i].contains(mapString)) {
- Rukkit.getRoundConfig().mapName = OfficialMap.maps[i];
- Rukkit.getRoundConfig().mapType = 0;
- try {
- con.currectRoom.broadcast(Packet.serverInfo(con.currectRoom.config));
- con.handler.ctx.writeAndFlush(Packet.serverInfo(con.currectRoom.config, true));
- } catch (IOException ignored) {}
- break;
- }
- }
- //ChannelGroups.broadcast(new Packet().chat(p.playerName, "-map " + cmd[1], p.playerIndex));
- return false;
- }
- int id = Integer.parseInt(args[0]);
+ String mapString = MapCommandSupport.quotedValue(args[0]);
+ int id = mapString == null
+ ? MapCommandSupport.mapIndex(args[0], OfficialMap.maps.length)
+ : MapCommandSupport.officialMapIndex(mapString);
+ if (id < 0) return false;
Rukkit.getRoundConfig().mapName = OfficialMap.maps[id];
Rukkit.getRoundConfig().mapType = 0;
+ try {
+ con.currectRoom.broadcast(Packet.serverInfo(con.currectRoom.config));
+ con.handler.ctx.writeAndFlush(Packet.serverInfo(con.currectRoom.config, true));
+ } catch (IOException ignored) {}
}
}
return false;
@@ -182,11 +175,12 @@ public boolean onSend(RoomConnection con, String[] args) {
if (type == 0) {
StringBuilder build = new StringBuilder();
List li = CustomMapLoader.getMapNameList();
+ int page = MapCommandSupport.pageIndex(args);
+ if (page < 0) return false;
if (args.length > 0) {
build.append("- CustomMaps - Page ").append(args[0]).append(" \n");
- int page = Integer.parseInt(args[0]) - 1;
- for (int i = page * 10;i < li.size();i++) {
- if (i > page * 10 + 10) break;
+ for (int i = page * MapCommandSupport.PAGE_SIZE;
+ i < MapCommandSupport.pageEnd(page, li.size()); i++) {
build.append(String.format("[%d] %s", i, li.get(i))).append("\n");
}
} else {
@@ -199,8 +193,9 @@ public boolean onSend(RoomConnection con, String[] args) {
} else {
if (con.player.isAdmin && args.length > 0) {
ArrayList mapList = CustomMapLoader.getMapNameList();
- int id = Integer.parseInt(args[0]);
- Rukkit.getRoundConfig().mapName = mapList.get(id).toString();
+ int id = MapCommandSupport.mapIndex(args[0], mapList.size());
+ if (id < 0) return false;
+ Rukkit.getRoundConfig().mapName = mapList.get(id);
Rukkit.getRoundConfig().mapType = 1;
try {
con.currectRoom.broadcast(Packet.serverInfo(con.currectRoom.config));
@@ -303,7 +298,8 @@ class QcCallback implements ChatCommandListener {
public boolean onSend(RoomConnection con, String[] args) {
if (args.length <= 0) return false;
getLogger().info("Player {} issued command: {}", con.player.name, args[0]);
- Rukkit.getCommandManager().executeChatCommand(con, args[0].substring(1));
+ Rukkit.getCommandManager().executeChatCommand(con,
+ CommandManager.normalizeNestedCommand(args[0]));
return false;
}
}
@@ -455,24 +451,39 @@ public boolean onSend(RoomConnection con, String[] args) {
class ShareCallback implements ChatCommandListener {
@Override
public boolean onSend(RoomConnection con, String[] args) {
- if (con.currectRoom.isGaming() || args.length < 1) {
- // Do nothing.
- } else {
- RoomConnectionManager ChannelGroups = con.currectRoom.connectionManager;
- switch (args[0]) {
- case "on":
- con.player.isSharingControl = true;
- ChannelGroups.broadcastServerMessage(con.player.name + "stopped Shared control!");
- break;
- case "off":
- con.player.isSharingControl = false;
- ChannelGroups.broadcastServerMessage(con.player.name + "started Shared control.");
- break;
- default:
- con.player.isSharingControl = false;
- ChannelGroups.broadcastServerMessage(con.player.name + "started Shared control!");
+ if (con == null || con.player == null || con.currectRoom == null) {
+ return false;
+ }
+ if (!con.currectRoom.config.sharedControl) {
+ con.sendServerMessage("[Shared control is not enabled in this game]");
+ return false;
+ }
+
+ String value = args != null && args.length > 0 ? args[0] : "";
+ RoomConnectionManager channelGroups = con.currectRoom.connectionManager;
+ if ("true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value)) {
+ if (!con.player.isSharingControl) {
+ con.player.isSharingControl = true;
+ channelGroups.broadcastServerMessage(
+ "[shared control now on for " + con.player.name + "]");
+ } else {
+ channelGroups.broadcastServerMessage(
+ "[shared control already on for " + con.player.name + "]");
+ }
+ return false;
+ }
+ if ("false".equalsIgnoreCase(value) || "off".equalsIgnoreCase(value)) {
+ if (con.player.isSharingControl) {
+ con.player.isSharingControl = false;
+ channelGroups.broadcastServerMessage(
+ "[shared control now off for " + con.player.name + "]");
+ } else {
+ channelGroups.broadcastServerMessage(
+ "[shared control already off for " + con.player.name + "]");
}
+ return false;
}
+ con.sendServerMessage("[Expected true or false]");
return false;
}
}
@@ -483,7 +494,7 @@ public boolean onSend(RoomConnection con, String[] args) {
if (con.currectRoom.isGaming() || !con.player.isAdmin || args.length < 1) {
// Do nothing.
} else {
- Rukkit.getRoundConfig().sharedControl = Boolean.parseBoolean(args[0]);
+ con.currectRoom.config.sharedControl = Boolean.parseBoolean(args[0]);
try {
con.currectRoom.broadcast(Packet.serverInfo(con.currectRoom.config));
con.handler.ctx.writeAndFlush(Packet.serverInfo(con.currectRoom.config, true));
diff --git a/src/test/java/cn/rukkit/plugin/internal/CommandPluginSimpleCommandMigrationTest.java b/src/test/java/cn/rukkit/plugin/internal/CommandPluginSimpleCommandMigrationTest.java
new file mode 100644
index 0000000..bc5f64d
--- /dev/null
+++ b/src/test/java/cn/rukkit/plugin/internal/CommandPluginSimpleCommandMigrationTest.java
@@ -0,0 +1,515 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.plugin.internal;
+
+import cn.rukkit.Rukkit;
+import cn.rukkit.command.CommandManager;
+import cn.rukkit.config.RoundConfig;
+import cn.rukkit.config.RukkitConfig;
+import cn.rukkit.game.NetworkPlayer;
+import cn.rukkit.game.mod.ModManager;
+import cn.rukkit.game.map.OfficialMap;
+import cn.rukkit.network.ConnectionHandler;
+import cn.rukkit.network.ConnectionState;
+import cn.rukkit.network.NetworkRoom;
+import cn.rukkit.network.RoomConnection;
+import cn.rukkit.network.core.handler.ServerConnectionHandler;
+import cn.rukkit.network.core.handler.ServerPacketHandlerManager;
+import cn.rukkit.network.core.packet.Packet;
+import cn.rukkit.network.core.packet.PacketType;
+import cn.rukkit.network.io.GameInputStream;
+import cn.rukkit.network.room.ServerRoom;
+import cn.rukkit.network.room.ServerRoomConnection;
+import cn.rukkit.plugin.PluginManager;
+import cn.rukkit.plugin.RukkitPlugin;
+import cn.rukkit.service.ThreadManager;
+import io.netty.channel.embedded.EmbeddedChannel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CommandPluginSimpleCommandMigrationTest {
+ private final List channels = new ArrayList<>();
+ private final List serverHandlers = new ArrayList<>();
+ private final List legacyHandlers = new ArrayList<>();
+ private Object previousConfig;
+ private Object previousRound;
+ private Object previousThreadManager;
+ private Object previousModManager;
+ private Object previousCommandManager;
+ private Object previousPluginManager;
+ private ThreadManager testThreadManager;
+ private CoreCommandPlugin coreCommandPlugin;
+ private CommandPlugin legacyCommandPlugin;
+
+ @BeforeEach
+ void installTestConfiguration() throws ReflectiveOperationException {
+ RukkitConfig config = new RukkitConfig();
+ config.maxRoom = 1;
+ config.maxPlayer = 2;
+ config.threadPoolCount = 3;
+ previousConfig = setStatic("config", config);
+ previousRound = setStatic("round", new RoundConfig());
+ previousModManager = setStatic("modManager", new ModManager());
+ testThreadManager = new ThreadManager(2);
+ previousThreadManager = setStatic("threadManager", testThreadManager);
+ previousCommandManager = setStatic("commandManager", new CommandManager());
+ previousPluginManager = setStatic("pluginManager", new PluginManager());
+
+ coreCommandPlugin = new CoreCommandPlugin();
+ coreCommandPlugin.onLoad();
+ }
+
+ @AfterEach
+ void restoreConfiguration() throws ReflectiveOperationException {
+ unregisterPluginListeners();
+ for (ServerConnectionHandler handler : serverHandlers) {
+ handler.stopTimeout();
+ }
+ for (ConnectionHandler handler : legacyHandlers) {
+ handler.stopTimeout();
+ }
+ for (EmbeddedChannel channel : channels) {
+ channel.finishAndReleaseAll();
+ }
+ testThreadManager.shutdown();
+
+ setStatic("config", previousConfig);
+ setStatic("round", previousRound);
+ setStatic("threadManager", previousThreadManager);
+ setStatic("modManager", previousModManager);
+ setStatic("commandManager", previousCommandManager);
+ setStatic("pluginManager", previousPluginManager);
+ }
+
+ @Test
+ void registersContextListenersForSimpleCommands() {
+ CommandManager manager = Rukkit.getCommandManager();
+
+ assertNotNull(manager.fetchCommand("version").getContextListener());
+ assertNotNull(manager.fetchCommand("state").getContextListener());
+ assertNotNull(manager.fetchCommand("help").getContextListener());
+ assertNull(manager.fetchCommand("version").getListener());
+ assertEquals(coreCommandPlugin, manager.fetchCommand("version").getContextListener());
+ }
+
+ @Test
+ void registersContextListenersForLowRiskRoomCommands() {
+ CommandManager manager = Rukkit.getCommandManager();
+
+ assertNotNull(manager.fetchCommand("t").getContextListener());
+ assertNotNull(manager.fetchCommand("self_team").getContextListener());
+ assertNotNull(manager.fetchCommand("chksum").getContextListener());
+ assertNotNull(manager.fetchCommand("maping").getContextListener());
+ assertNotNull(manager.fetchCommand("list").getContextListener());
+ assertNotNull(manager.fetchCommand("surrender").getContextListener());
+ }
+
+ @Test
+ void everyCoreCommandRegisteredByCoreCommandPluginHasContextBehavior() {
+ CommandManager manager = Rukkit.getCommandManager();
+
+ assertTrue(manager.getLoadedCommand().values().stream()
+ .allMatch(command -> command.getContextListener() != null));
+ }
+
+ @Test
+ void executesSimpleCommandsThroughMigratedConnection() throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "version");
+ Packet version = readServerMessage(fixture.channel);
+ assertEquals("Rukkit Server v" + cn.rukkit.util.VersionUtil.getVersion()
+ + "\nRukkit Plugin API v" + Rukkit.PLUGIN_API_VERSION,
+ readChatMessage(version));
+
+ manager.executeChatCommand(fixture.connection, "help");
+ String help = readChatMessage(readServerMessage(fixture.channel));
+ assertTrue(help.startsWith("- Help - Page 1"));
+ assertTrue(help.contains(" : "));
+
+ manager.executeChatCommand(fixture.connection, "state");
+ String state = readChatMessage(readServerMessage(fixture.channel));
+ assertTrue(state.contains("Connections: 1"));
+ assertTrue(state.contains("ThreadManager Tasks: "));
+ assertTrue(state.endsWith("/3"));
+ }
+
+ @Test
+ void qcAcceptsOptionalPrefixThroughMigratedConnection() throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+ String expected = "Rukkit Server v" + cn.rukkit.util.VersionUtil.getVersion()
+ + "\nRukkit Plugin API v" + Rukkit.PLUGIN_API_VERSION;
+
+ for (String prefix : new String[]{"", ".", "-"}) {
+ manager.executeChatCommand(fixture.connection, "qc " + prefix + "version");
+ assertEquals(expected, readChatMessage(readServerMessage(fixture.channel)));
+ }
+ }
+
+ @Test
+ void listsOfficialMapsInNonOverlappingTenItemPagesThroughMigratedConnection()
+ throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "maps 1");
+ String firstPage = readChatMessage(readServerMessage(fixture.channel));
+ assertTrue(firstPage.contains("[0] " + OfficialMap.maps[0]));
+ assertTrue(firstPage.contains("[9] " + OfficialMap.maps[9]));
+ assertFalse(firstPage.contains("[10] " + OfficialMap.maps[10]));
+
+ manager.executeChatCommand(fixture.connection, "maps 2");
+ String secondPage = readChatMessage(readServerMessage(fixture.channel));
+ assertFalse(secondPage.contains("[9] " + OfficialMap.maps[9]));
+ assertTrue(secondPage.contains("[10] " + OfficialMap.maps[10]));
+ assertTrue(secondPage.contains("[19] " + OfficialMap.maps[19]));
+ assertFalse(secondPage.contains("[20] " + OfficialMap.maps[20]));
+ }
+
+ @Test
+ void selectsOfficialMapByTheDisplayedNameThroughMigratedConnection() throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ fixture.connection.player.isAdmin = true;
+
+ Rukkit.getCommandManager().executeChatCommand(
+ fixture.connection, "map '" + OfficialMap.maps[0] + "'");
+
+ assertEquals(OfficialMap.maps[0], fixture.connection.currectRoom.config.mapName);
+ assertServerInfoPair(fixture.channel);
+ }
+
+ @Test
+ void listsEmptyCustomMapDirectoryWithoutFailingThroughMigratedConnection()
+ throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+
+ Rukkit.getCommandManager().executeChatCommand(fixture.connection, "cmaps");
+ String message = readChatMessage(readServerMessage(fixture.channel));
+ assertTrue(message.startsWith("- Help - Page 1"));
+ }
+
+ @Test
+ void keepsVersionAvailableThroughLegacyConnection() throws Exception {
+ installLegacyCommands();
+ LegacyConnectionFixture fixture = newLegacyConnection();
+
+ Rukkit.getCommandManager().executeChatCommand(fixture.connection, "version");
+
+ cn.rukkit.network.packet.Packet actual = fixture.channel.readOutbound();
+ assertNotNull(actual);
+ assertArrayEquals(
+ cn.rukkit.network.packet.Packet.chat(
+ "SERVER",
+ "Rukkit Server v" + cn.rukkit.util.VersionUtil.getVersion()
+ + "\nRukkit Plugin API v" + Rukkit.PLUGIN_API_VERSION,
+ -1).bytes,
+ actual.bytes);
+ }
+
+ @Test
+ void keepsMapCommandBehaviorThroughLegacyConnection() throws Exception {
+ installLegacyCommands();
+ LegacyConnectionFixture fixture = newLegacyConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "maps 1");
+ cn.rukkit.network.packet.Packet firstPagePacket = fixture.channel.readOutbound();
+ assertNotNull(firstPagePacket);
+ String firstPage = readLegacyChatMessage(firstPagePacket);
+ assertTrue(firstPage.contains("[0] " + OfficialMap.maps[0]));
+ assertTrue(firstPage.contains("[9] " + OfficialMap.maps[9]));
+ assertFalse(firstPage.contains("[10] " + OfficialMap.maps[10]));
+
+ fixture.connection.player.isAdmin = true;
+ manager.executeChatCommand(
+ fixture.connection, "map '" + OfficialMap.maps[0] + "'");
+ assertEquals(OfficialMap.maps[0], fixture.connection.currectRoom.config.mapName);
+ cn.rukkit.network.packet.Packet mapInfo = fixture.channel.readOutbound();
+ assertNotNull(mapInfo);
+ assertEquals(cn.rukkit.network.packet.Packet.PACKET_SERVER_INFO, mapInfo.type);
+ }
+
+ @Test
+ void listsEmptyCustomMapDirectoryWithoutFailingThroughLegacyConnection()
+ throws Exception {
+ installLegacyCommands();
+ LegacyConnectionFixture fixture = newLegacyConnection();
+
+ Rukkit.getCommandManager().executeChatCommand(fixture.connection, "cmaps");
+ cn.rukkit.network.packet.Packet messagePacket = fixture.channel.readOutbound();
+ assertNotNull(messagePacket);
+ assertTrue(readLegacyChatMessage(messagePacket).startsWith("- Help - Page 1"));
+ }
+
+ @Test
+ void qcAcceptsOptionalPrefixThroughLegacyConnection() throws Exception {
+ installLegacyCommands();
+ LegacyConnectionFixture fixture = newLegacyConnection();
+ byte[] expected = cn.rukkit.network.packet.Packet.chat(
+ "SERVER",
+ "Rukkit Server v" + cn.rukkit.util.VersionUtil.getVersion()
+ + "\nRukkit Plugin API v" + Rukkit.PLUGIN_API_VERSION,
+ -1).bytes;
+
+ for (String prefix : new String[]{"", ".", "-"}) {
+ Rukkit.getCommandManager().executeChatCommand(
+ fixture.connection, "qc " + prefix + "version");
+ cn.rukkit.network.packet.Packet actual = fixture.channel.readOutbound();
+ assertNotNull(actual);
+ assertArrayEquals(expected, actual.bytes);
+ }
+ }
+
+ @Test
+ void executesLowRiskRoomCommandsThroughMigratedConnection() throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "self_team 3");
+ assertEquals(2, fixture.connection.player.team);
+
+ manager.executeChatCommand(fixture.connection, "t hello team");
+ Packet teamMessage = fixture.channel.readOutbound();
+ assertNotNull(teamMessage);
+ assertEquals(PacketType.SEND_CHAT, teamMessage.type);
+ assertTrue(readChatMessage(teamMessage).endsWith("hello team"));
+
+ manager.executeChatCommand(fixture.connection, "list");
+ Packet playerList = readServerMessage(fixture.channel);
+ assertTrue(readChatMessage(playerList).contains("Alice (Team 2)"));
+
+ manager.executeChatCommand(fixture.connection, "maping 10 20");
+ Packet ping = fixture.channel.readOutbound();
+ assertNotNull(ping);
+ assertEquals(PacketType.TICK, ping.type);
+
+ manager.executeChatCommand(fixture.connection, "chksum");
+ Packet checksum = fixture.channel.readOutbound();
+ assertNotNull(checksum);
+ assertEquals(PacketType.SYNC_CHECKSUM, checksum.type);
+
+ manager.executeChatCommand(fixture.connection, "surrender");
+ assertTrue(fixture.connection.player.isSurrounded);
+ Packet surrenderAction = fixture.channel.readOutbound();
+ Packet surrenderMessage = fixture.channel.readOutbound();
+ assertNotNull(surrenderAction);
+ assertNotNull(surrenderMessage);
+ assertEquals(PacketType.TICK, surrenderAction.type);
+ assertEquals(PacketType.SEND_CHAT, surrenderMessage.type);
+ }
+
+ @Test
+ void executesCoreRoomConfigurationCommandsThroughMigratedConnection() throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ fixture.connection.player.isAdmin = true;
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "fog los");
+ assertEquals(2, fixture.connection.currectRoom.config.fogType);
+ assertServerInfoPair(fixture.channel);
+
+ manager.executeChatCommand(fixture.connection, "startingunits 7");
+ assertEquals(7, fixture.connection.currectRoom.config.startingUnits);
+ assertServerInfoPair(fixture.channel);
+
+ manager.executeChatCommand(fixture.connection, "income 2.5");
+ assertEquals(2.5f, fixture.connection.currectRoom.config.income);
+ assertServerInfoPair(fixture.channel);
+
+ manager.executeChatCommand(fixture.connection, "credits 1234");
+ assertEquals(1234, fixture.connection.currectRoom.config.credits);
+ assertServerInfoPair(fixture.channel);
+
+ manager.executeChatCommand(fixture.connection, "nukes true");
+ assertEquals(false, fixture.connection.currectRoom.config.disableNuke);
+ assertServerInfoPair(fixture.channel);
+
+ fixture.connection.currectRoom.config.sharedControl = true;
+ manager.executeChatCommand(fixture.connection, "share on");
+ assertTrue(fixture.connection.player.isSharingControl);
+ Packet shareMessage = fixture.channel.readOutbound();
+ assertNotNull(shareMessage);
+ assertEquals(PacketType.SEND_CHAT, shareMessage.type);
+
+ manager.executeChatCommand(fixture.connection, "map 1");
+ assertEquals(0, fixture.connection.currectRoom.config.mapType);
+ assertServerInfoPair(fixture.channel);
+ }
+
+ @Test
+ void shareMatchesOriginalToggleAndPermissionBehaviorThroughMigratedConnection()
+ throws Exception {
+ ServerConnectionFixture fixture = newServerConnection();
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "share on");
+ assertFalse(fixture.connection.player.isSharingControl);
+ assertEquals("[Shared control is not enabled in this game]",
+ readChatMessage(readServerMessage(fixture.channel)));
+
+ fixture.connection.currectRoom.config.sharedControl = true;
+ manager.executeChatCommand(fixture.connection, "share on");
+ assertTrue(fixture.connection.player.isSharingControl);
+ assertEquals("[shared control now on for Alice]",
+ readChatMessage(readServerMessage(fixture.channel)));
+
+ manager.executeChatCommand(fixture.connection, "share on");
+ assertEquals("[shared control already on for Alice]",
+ readChatMessage(readServerMessage(fixture.channel)));
+
+ manager.executeChatCommand(fixture.connection, "share off");
+ assertFalse(fixture.connection.player.isSharingControl);
+ assertEquals("[shared control now off for Alice]",
+ readChatMessage(readServerMessage(fixture.channel)));
+
+ manager.executeChatCommand(fixture.connection, "share maybe");
+ assertFalse(fixture.connection.player.isSharingControl);
+ assertEquals("[Expected true or false]",
+ readChatMessage(readServerMessage(fixture.channel)));
+ }
+
+ @Test
+ void shareMatchesOriginalToggleBehaviorThroughLegacyConnection() throws Exception {
+ installLegacyCommands();
+ LegacyConnectionFixture fixture = newLegacyConnection();
+ fixture.connection.currectRoom.config.sharedControl = true;
+ CommandManager manager = Rukkit.getCommandManager();
+
+ manager.executeChatCommand(fixture.connection, "share true");
+ assertTrue(fixture.connection.player.isSharingControl);
+ assertEquals("[shared control now on for Alice]",
+ readLegacyChatMessage(fixture.channel.readOutbound()));
+
+ manager.executeChatCommand(fixture.connection, "share false");
+ assertFalse(fixture.connection.player.isSharingControl);
+ assertEquals("[shared control now off for Alice]",
+ readLegacyChatMessage(fixture.channel.readOutbound()));
+ }
+
+ private ServerConnectionFixture newServerConnection() {
+ ServerConnectionHandler handler = new ServerConnectionHandler(
+ new ServerPacketHandlerManager());
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ serverHandlers.add(handler);
+ channels.add(channel);
+
+ ServerRoom room = new ServerRoom(0);
+ ServerRoomConnection connection = new ServerRoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "simple-command-test";
+ room.connectionManager.add(connection);
+ handler.setConn(connection);
+ handler.setState(ConnectionState.IN_ROOM);
+ return new ServerConnectionFixture(channel, connection);
+ }
+
+ private LegacyConnectionFixture newLegacyConnection() {
+ ConnectionHandler handler = new ConnectionHandler();
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+ legacyHandlers.add(handler);
+ channels.add(channel);
+
+ NetworkRoom room = new NetworkRoom(0);
+ RoomConnection connection = new RoomConnection(handler, room);
+ connection.player = new NetworkPlayer(connection);
+ connection.player.name = "Alice";
+ connection.player.uuid = "legacy-simple-command-test";
+ room.connectionManager.add(connection);
+ handler.setState(ConnectionState.IN_ROOM);
+ return new LegacyConnectionFixture(channel, connection);
+ }
+
+ private static Packet readServerMessage(EmbeddedChannel channel) {
+ Packet packet = channel.readOutbound();
+ assertNotNull(packet);
+ assertEquals(PacketType.SEND_CHAT, packet.type);
+ return packet;
+ }
+
+ private static void assertServerInfoPair(EmbeddedChannel channel) {
+ Packet broadcast = channel.readOutbound();
+ Packet admin = channel.readOutbound();
+ assertNotNull(broadcast);
+ assertNotNull(admin);
+ assertEquals(PacketType.SERVER_INFO, broadcast.type);
+ assertEquals(PacketType.SERVER_INFO, admin.type);
+ }
+
+ private static String readChatMessage(Packet packet) throws IOException {
+ return new GameInputStream(packet).readString();
+ }
+
+ private static String readLegacyChatMessage(cn.rukkit.network.packet.Packet packet)
+ throws IOException {
+ return new GameInputStream(packet.bytes).readString();
+ }
+
+ private void unregisterPluginListeners() throws ReflectiveOperationException {
+ Field field = RukkitPlugin.class.getDeclaredField("listeners");
+ field.setAccessible(true);
+ PluginManager pluginManager = Rukkit.getPluginManager();
+ if (pluginManager != null) {
+ for (RukkitPlugin plugin : new RukkitPlugin[]{coreCommandPlugin, legacyCommandPlugin}) {
+ if (plugin == null) {
+ continue;
+ }
+ @SuppressWarnings("unchecked")
+ List listeners =
+ (List) field.get(plugin);
+ for (cn.rukkit.event.EventListener listener : listeners) {
+ pluginManager.unregisterEventListener(listener);
+ }
+ }
+ }
+ }
+
+ private void installLegacyCommands() {
+ try {
+ setStatic("commandManager", new CommandManager());
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException(e);
+ }
+ legacyCommandPlugin = new CommandPlugin();
+ legacyCommandPlugin.onLoad();
+ assertNull(Rukkit.getCommandManager().fetchCommand("version").getContextListener());
+ }
+
+ private static Object setStatic(String name, Object value) throws ReflectiveOperationException {
+ Field field = Rukkit.class.getDeclaredField(name);
+ field.setAccessible(true);
+ Object previous = field.get(null);
+ field.set(null, value);
+ return previous;
+ }
+
+ private record ServerConnectionFixture(EmbeddedChannel channel,
+ ServerRoomConnection connection) {
+ }
+
+ private record LegacyConnectionFixture(EmbeddedChannel channel,
+ RoomConnection connection) {
+ }
+}
From cee4e69443fa7c83fcdd342533f140ec3f439cb3 Mon Sep 17 00:00:00 2001
From: wtbdev
Date: Mon, 10 Aug 2026 13:59:54 +0800
Subject: [PATCH 9/9] fix(sync): update generic default save
---
src/main/resources/defaultSave | Bin 5671 -> 1877 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/src/main/resources/defaultSave b/src/main/resources/defaultSave
index 89a112a691b809e2581c7d9080c85636bb6ff1da..a35ce42f01d11d8f54fd09b319605eb2778b9443 100644
GIT binary patch
delta 1851
zcmV-B2gLZNEY%K>FMkIXABzY800000008Zndu$X%7{KoY+R}0qD$2uRls_yeLc1kt
z5JdVGEzrk7ODLRkdvkZg?ar>VyU^1hXb7kQZH$USd;}E%TQyLXknm7>2tL9~1SNn`
ze1Sko4Frtm>|T4_mT%ieV`7Y(TyA&gH{bWo%=fx;4G;}YSAXOgl;~Q4q=*rhD`UAU
z&GweRc-ZA~x%nsR@*q@zkz8s}ElkwI#k;4N?>k+CJ3u}Z)G_lLjNWnO*%<(yF8YwH
zhKZjjhISv;#Y@``0obrkm``VjYD6ar{rS8!x+gOX8o+D;;^yo~e-HyRlKhJ7$EE90rhk|rU18IfFMsI>w3-f(S6GqtO)-F_acnA-ZjsT+d?h>=}URZ)d#1F1?|jgPsy8`gLad2nRih^*mqFxYj{Wg7FH&
zZ|heMP$_LoKD;pR8Heq)#>)^3;bjYp=5z&B$w-pw^MA*m2cTpQO~aZVx$kHNFx;09
zS0Wn1fv3J(sySF%r78^fI<7xRSp(bBTfBae^#Y~
zcf>8^4s@~N4rtFs?y5*HrXfNxW?Maz-vp>ZxK724U>Q>#Nri~$l>2OmPnnOIXeL!0*i+y47=Yhs
z#~CIxs4x)+Mx2{?6BLU<*qAX|8g|8sjv41lHIdRvnZkXKamEUZDJTyoc=ILiz
zj?f3%iZDj=qxtz&{4|FdYiprQXdwki*ClKo`mAmFvjA*>g-xM6D~nj=CaoB-lQ?oy
ziKY<=n`+m5au{;?6pO1e{GO|`KY|u7`odTuqM~w*bi$b*p)4c%NV`ERx4s$#pi~L@
z6@M$`MVF-hS_f^C=#!9!ndz7#bpN(a4gZ6>gSLy8{!q7`Ms1mLHSH4l7w0G)Q@>z
zK-*_R1qO{^R@qzvtvjQgD8j0wL~(!|pMRbMuF;^TdHDp`m#0@$brp
z&U9cq=S2~$1cR^8dC5)!@3nbk6Pk3z$+9xj31YtV%}#q&W*#u*N*}pt$Zn^DnMx=L
z8MNW^<507cY$Du`^7FPscC<>cOwE0udNpKfP(lp*a#>d`7m
z(cfXGp#E}5N4!1ZHzZvh4E4%Rynk1azQ3@bdxN-V>Q+lsvu)1t?$0TKdxey&Juh}|
zk(EdTIEbl+_h#)|(!E7eTTngOlm%r(PX~PV$qSG-cRGHm&|-7v53VbWc5eZX5-bIH
zXWaX8=|%w0?1Y;wZDM1h$#bDL<>s#Dc!e$Uv8&xXnxd=t
zOz|svAw&KwS7s`d!4g2V`72O;n;d`^V+Q#&9^_@npkf%a!j_#SP+Im-JM`H4yk^KK
z#n4qn)4e(6+med90))pmcz@4)O}ij!Xw1Zs=~UV@`qmL
zn=Bcr+E^_@81Ey(V}EC>!W`@00N)f%;>qNek%BsEj4sz%(~D{*yNdSjaNV=mdbAW|
zkLNe;__uYX#s|_H<497ugrb$#lPDem5u$lm;u(RSxb
literal 5671
zcmdUxc~FyA+s64uiwjVsSXpGz7u*UcHGm*Xr0fP+1Z5{8kQgF_J!~Nbic~QmARxOe
z5!v^BNs$7wlduaRVAx|4APEpc$cJsGov-t~GyT&(Gv}G-%spr3JiocF`@Z-@d;|Ra
z-Q8?ld?7Br?p7{=?tFZFm51vAAD^(_;Z_gf>Fw+8=LbW0@$r4TDjR$Ju=x3*P6@p@
zZIi{fL34LOKGhvl4UcZIq?=k;+LqqVZJHk<=$>{JG-m$b7=w*SCQb)HCfsO!qA%Y<
z*%8?>AKopCuEQF`9G{c~9zdLPBkUn%y-_^%WO5bZ9wMkSv^_0A%~O}cnG5Pg<~^?zNUU7xmh)*1XIJN9
zWYK2J4=&a(lY<_N7KUf~Fm-$JtKEx|f)}`-#P`?<&tH92_C1BuZqdZntzr5t8M9`@
zfV)05OaMwJw0wUm2C@dd2||MvEV6yO;;cvWI8zb9KH&?FFLRS>!TI(%jc+9L+M?gOjFYe-dguBi*?`vMx
z>n*c?tKU`D*`op|$
z;4>-7#~3H@WiMWgKp?fh`Sm8#wes=&5{Wavrnh&|dAA|MOtyNTismk%hwVA`Kk)ZB
z%cMRb7^u#LacJ2H_n*LHlFrX!Vja3~>uoOga6fMKxM{~aoXoi0smCgOJgq=)Q=m-1
zFl%QI=0Pb7c3UV18kEHt{)R5OPCZw$NqiSniVd6i4xpkQ=5Y(bn$4J=E{X+ttR
zdwWo!+AG`eRzPE8UDfvEy?2CC4b=OhMpiU+b1FrNuFzf!um5S-qe%{-YfQ~+Z!
z&HEQ7+ZEt%9Sl!mgS@8+TZs$(G8wEs3C~)(>{Tq1vLqI5-utOYBzZ0tK?@n07^r%4
zzN>6WpuJj&ywPdYqh>De3M=JCML%?JRCcuIjS%fR;`;>6Xwg($BiV^QXrEhCvx6$)
zu#u3m5wy5-=9CQBerBT$V64Lu+<&x^yW-wvIMaCpBPiB@+gtpA+@TB&>VIiA*?m&BWsB|Pn1GDja}LWrG^Dha^oMlt>=bt%TnFSeio~b2Jdh~YiP?7qhKio!?k1}
zGa+J~8-FhN_Q!<5-c+BJ&GDS~$8kZ1L3<{eD3@ZVYeRdc?EKL>JJQZH9>Jv@AZoXQ
zq!2oe4jk_D5d(=___N;0D?r
z5YYT%`y{n(18axq+gx0hRVolz-%X9FPN50J-0p3ux@$I6`b{cuvo3!J5{`LUytD`i
z_Vy3*^IFX+0sOF>5|h}`3THvXU$7+Z%2ZtBDPHbAR?gX@eX7V>W6_St1{+RHc1CvF
zkgcsJll$RzymQi$rSdC#WFb-Wj3t~vBHP6I2MjtAwVsZ%)tHetJBFo41p`0zH}4Cl
zB_L3w?n~(Fl&;NV5&iK%@oZ2D3PD
z@)C!piSi~D4WPHNT-!l#(V73dyD^}*!p
z3oBbHEAV%+JMP*t4oNO$u?nJO6L*tSPjsOSWAO=(x+Iv(Qe2VKAPID4qM=78fLL6Z
zaX^2h0JA
zPWaWlfXmWW+WNy&_-5cM8{kxcXYR{vgyePA?t+ogS?$Kh-4SbEgw|f)fZ_^e+Y#Kw
z2Kk2bY0Zw-mu;e$rtmvfg+z-IX`K&huQtZsb^<`BAs26NKasYA%J4JFWv*D0@20;_
zk(%bFYpVJz@oH?pEtWlFN&?{v^P|s}k%7Ui61pq7$zh@MOwQKbK0HPN#
zoxy`!tpJe9waTIExVtkPTT(|L+ONUN1`r3%BX@H?Ag?~sGWAOmL+*h${o=57Y!}yn
zJ@67t%|xcas^6mK^jvBkoi}H)d&{4&dCUL$O2ldbBVyH!b_CzsdMq!g{BY3@S2lny
z{~ieUu?c4-^zIYe$n317(0mMBtWX4BLJAwT{;-#BkA|9~ipIbL9KYPC!gN;Y2?r&5;!4xbb;otIpfFwB_jL%PMMk>E~k!4>*^Va54407SYSZ9N+O5XZrFDt
z^+y(UvT$`D7V_5+>U9l(D>1Og%~@2&|9rW>GbCN7Y`lmxqYaWO!-&5aFk~}Q@-aa0
zXB{}<D9zB)Aya-xe4oJ>bQiv#ndbRq0EWKE^1yv~v{TUvHBy%86W^%If$f*N{*j
z?v
zr4~26wJ3ixZEnUo^(I-Rbeg`9Uq*hFk!kR1@$16|KAjEta^*KaYkAnhbsCvLXli-P
zB58v2;WvB%H-5vnw|hs|%1BZoumD>-vM;nvKOXscx+zS~`6%N;e9e{hprNWatCW9^
zBT5KLdB}TePN~-TlwG+JXmlxsBuzv*s1iw52+50iq3zQ%gmal!m7MUqniY0AV*CPo
zlZz>fYQJJVu73Bj1h$=IrTr7^f)IQ=boUWOefa`Z
z6h46Q=9PqpueCA}TLMw#s|E@;-f;4>c=ZbZz`bYq@td1}*I)SbJ^ZH4zWg5mbK2-B
zf4Mi)>A$qUH!;8Gw8}-rck;+aP2$jY<`+CVR^sf%<@bPocZ_(2bo&>iptTCdB$2hd
zI*j0$HUmS(`!XWm#Hn2WWDJ7kvd@0PxzJ*4ly07K^x-JAvB#4_BPDLml;EF6vpoNA
zjA*9kO&aVN!ZcQkKBp_~sIv}|KaFCs3XNLb#0Z#e4e4`?z-Rr7-1}z$Q_SYQ
zsFiX;vkwPHEgXax8A~vHA`QEuq=Js(YDPX7&pXo5SBI1usK}lb9T=H*#+WIZ)}#J5
zHvB(H568x#$u4UL*&(CKBGSQ#KRX}7yk<`H|937nO@q3#fuHbU%Kv+udpxE5cy)B$
zoBFolFo-M2o!>2V1Qs9O9x|_DRCtW=0j!P+qCKYW`Sngnt@lZas)x)eLWV6Pp
zYld3dFYT!t_|dN*U~KKUb-(f>V2c|9wEZLr1NhlYQ83RHG{Zn>>E*>
zp&p6M8-Hxp071~u13xtX%D_Z95P2{--_G#iZ-Kxkb2NDnK9JkQ
zK=AKm#RI0a|2Qo`amYA43vDC(RRjE4tC?D!&FQdXtqkELW>Ynyct>JwDi>Yz#QBsp
R_)cnB>E1UuXJDqU^S_IT%%A`O