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