diff --git a/client/src/main/java/nl/andrewl/aos2_client/Client.java b/client/src/main/java/nl/andrewl/aos2_client/Client.java index 40ca682..f4c62b1 100644 --- a/client/src/main/java/nl/andrewl/aos2_client/Client.java +++ b/client/src/main/java/nl/andrewl/aos2_client/Client.java @@ -93,7 +93,7 @@ public class Client implements Runnable { playerSource = new SoundSource(); long lastFrameAt = System.currentTimeMillis(); - while (!gameRenderer.windowShouldClose()) { + while (!gameRenderer.windowShouldClose() && !communicationHandler.isDone()) { long now = System.currentTimeMillis(); float dt = (now - lastFrameAt) / 1000f; world.processQueuedChunkUpdates(); diff --git a/client/src/main/java/nl/andrewl/aos2_client/CommunicationHandler.java b/client/src/main/java/nl/andrewl/aos2_client/CommunicationHandler.java index 5f45e7a..afac7d2 100644 --- a/client/src/main/java/nl/andrewl/aos2_client/CommunicationHandler.java +++ b/client/src/main/java/nl/andrewl/aos2_client/CommunicationHandler.java @@ -39,6 +39,7 @@ public class CommunicationHandler { private ExtendedDataOutputStream out; private ExtendedDataInputStream in; private int clientId; + private boolean done; public CommunicationHandler(Client client) { this.client = client; @@ -71,7 +72,7 @@ public class CommunicationHandler { log.debug("Initial data received."); establishDatagramConnection(); log.info("Connection to server established. My client id is {}.", clientId); - new Thread(new TcpReceiver(in, client::onMessageReceived)).start(); + new Thread(new TcpReceiver(in, client::onMessageReceived).withShutdownHook(this::shutdown)).start(); new Thread(new UdpReceiver(datagramSocket, (msg, packet) -> client.onMessageReceived(msg))).start(); } else { throw new IOException("Server returned an unexpected message: " + response); @@ -84,9 +85,15 @@ public class CommunicationHandler { datagramSocket.close(); } catch (IOException e) { e.printStackTrace(); + } finally { + done = true; } } + public boolean isDone() { + return done; + } + public void sendMessage(Message msg) { try { Net.write(msg, out); diff --git a/core/src/main/java/nl/andrewl/aos_core/UsernameChecker.java b/core/src/main/java/nl/andrewl/aos_core/UsernameChecker.java new file mode 100644 index 0000000..57f3fb3 --- /dev/null +++ b/core/src/main/java/nl/andrewl/aos_core/UsernameChecker.java @@ -0,0 +1,14 @@ +package nl.andrewl.aos_core; + +import java.util.regex.Pattern; + +public class UsernameChecker { + private static final int MIN_LENGTH = 3; + private static final int MAX_LENGTH = 24; + private static final Pattern pattern = Pattern.compile("[a-zA-Z]+[a-zA-Z\\d-_]*"); + + public static boolean isValid(String username) { + if (username.length() < MIN_LENGTH || username.length() > MAX_LENGTH) return false; + return pattern.matcher(username).matches(); + } +} diff --git a/core/src/main/java/nl/andrewl/aos_core/net/UdpReceiver.java b/core/src/main/java/nl/andrewl/aos_core/net/UdpReceiver.java index e73d6df..a970179 100644 --- a/core/src/main/java/nl/andrewl/aos_core/net/UdpReceiver.java +++ b/core/src/main/java/nl/andrewl/aos_core/net/UdpReceiver.java @@ -18,10 +18,16 @@ public class UdpReceiver implements Runnable { private final DatagramSocket socket; private final UdpMessageHandler handler; + private final Runnable shutdownHook; public UdpReceiver(DatagramSocket socket, UdpMessageHandler handler) { + this(socket, handler, null); + } + + public UdpReceiver(DatagramSocket socket, UdpMessageHandler handler, Runnable shutdownHook) { this.socket = socket; this.handler = handler; + this.shutdownHook = shutdownHook; } @Override @@ -39,12 +45,11 @@ public class UdpReceiver implements Runnable { } e.printStackTrace(); } catch (EOFException e) { - System.out.println("EOF!"); break; } catch (IOException e) { e.printStackTrace(); } } - System.out.println("UDP receiver shut down."); + if (shutdownHook != null) shutdownHook.run(); } } diff --git a/server/pom.xml b/server/pom.xml index de32168..8f1322e 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -22,6 +22,17 @@ aos2-core ${parent.version} + + info.picocli + picocli-shell-jline3 + 4.6.3 + + + + org.fusesource.jansi + jansi + 2.4.0 + diff --git a/server/src/main/java/nl/andrewl/aos2_server/ClientCommunicationHandler.java b/server/src/main/java/nl/andrewl/aos2_server/ClientCommunicationHandler.java index 6953db2..d5ed537 100644 --- a/server/src/main/java/nl/andrewl/aos2_server/ClientCommunicationHandler.java +++ b/server/src/main/java/nl/andrewl/aos2_server/ClientCommunicationHandler.java @@ -2,6 +2,7 @@ package nl.andrewl.aos2_server; import nl.andrewl.aos2_server.model.ServerPlayer; import nl.andrewl.aos_core.Net; +import nl.andrewl.aos_core.UsernameChecker; import nl.andrewl.aos_core.model.item.ItemStack; import nl.andrewl.aos_core.model.world.Chunk; import nl.andrewl.aos_core.model.world.WorldIO; @@ -91,6 +92,18 @@ public class ClientCommunicationHandler { Message msg = Net.read(in); if (msg instanceof ConnectRequestMessage connectMsg) { log.debug("Received connect request from player \"{}\"", connectMsg.username()); + // Ensure the connection is valid. + if (!UsernameChecker.isValid(connectMsg.username())) { + Net.write(new ConnectRejectMessage("Invalid username."), out); + socket.close(); + return; + } + if (server.getPlayerManager().getPlayers().stream().anyMatch(p -> p.getUsername().equals(connectMsg.username()))) { + Net.write(new ConnectRejectMessage("Username is already taken."), out); + socket.close(); + return; + } + // Try to set the TCP timeout back to 0 now that we've got the correct request. socket.setSoTimeout(0); this.clientAddress = socket.getInetAddress(); diff --git a/server/src/main/java/nl/andrewl/aos2_server/PlayerManager.java b/server/src/main/java/nl/andrewl/aos2_server/PlayerManager.java index 83d0892..1ee674a 100644 --- a/server/src/main/java/nl/andrewl/aos2_server/PlayerManager.java +++ b/server/src/main/java/nl/andrewl/aos2_server/PlayerManager.java @@ -20,6 +20,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.DatagramPacket; import java.util.*; +import java.util.regex.Pattern; /** * This component is responsible for managing the set of players connected to @@ -73,6 +74,21 @@ public class PlayerManager { return players.get(id); } + public Optional findByIdOrName(String text) { + Pattern p = Pattern.compile("\\d+"); + if (p.matcher(text).matches() && text.length() < 8) { + int id = Integer.parseInt(text); + return Optional.ofNullable(getPlayer(id)); + } else { + String finalText = text.trim().toLowerCase(); + List matches = getPlayers().stream() + .filter(player -> player.getUsername().trim().toLowerCase().equals(finalText)) + .toList(); + if (matches.size() == 1) return Optional.of(matches.get(0)); + return Optional.empty(); + } + } + public Collection getPlayers() { return Collections.unmodifiableCollection(players.values()); } diff --git a/server/src/main/java/nl/andrewl/aos2_server/Server.java b/server/src/main/java/nl/andrewl/aos2_server/Server.java index ae07ccc..69b75b7 100644 --- a/server/src/main/java/nl/andrewl/aos2_server/Server.java +++ b/server/src/main/java/nl/andrewl/aos2_server/Server.java @@ -1,5 +1,6 @@ package nl.andrewl.aos2_server; +import nl.andrewl.aos2_server.cli.ServerCli; import nl.andrewl.aos2_server.config.ServerConfig; import nl.andrewl.aos2_server.logic.WorldUpdater; import nl.andrewl.aos2_server.model.ServerPlayer; @@ -60,6 +61,7 @@ public class Server implements Runnable { while (running) { acceptClientConnection(); } + log.info("Shutting down the server."); playerManager.deregisterAll(); worldUpdater.shutdown(); datagramSocket.close(); // Shuts down the UdpReceiver. @@ -68,6 +70,20 @@ public class Server implements Runnable { } catch (IOException e) { throw new RuntimeException(e); } + log.info("Shutdown complete."); + } + + public boolean isRunning() { + return running; + } + + public void shutdown() { + running = false; + try { + serverSocket.close(); + } catch (IOException e) { + e.printStackTrace(); + } } public void handleUdpMessage(Message msg, DatagramPacket packet) { @@ -136,6 +152,8 @@ public class Server implements Runnable { configPaths.add(Path.of(args[0].trim())); } ServerConfig cfg = Config.loadConfig(ServerConfig.class, configPaths, new ServerConfig()); - new Server(cfg).run(); + Server server = new Server(cfg); + new Thread(server).start(); + ServerCli.start(server); } } diff --git a/server/src/main/java/nl/andrewl/aos2_server/cli/PlayersCommand.java b/server/src/main/java/nl/andrewl/aos2_server/cli/PlayersCommand.java new file mode 100644 index 0000000..db73cda --- /dev/null +++ b/server/src/main/java/nl/andrewl/aos2_server/cli/PlayersCommand.java @@ -0,0 +1,49 @@ +package nl.andrewl.aos2_server.cli; + +import nl.andrewl.aos2_server.model.ServerPlayer; +import nl.andrewl.aos_core.model.Player; +import picocli.CommandLine; + +import java.util.Collection; +import java.util.Comparator; + +@CommandLine.Command( + name = "players", + description = "Commands for interacting with the players on the server.", + mixinStandardHelpOptions = true +) +public class PlayersCommand { + @CommandLine.ParentCommand ServerCli cli; + + @CommandLine.Command(description = "Lists all online players.") + public void list() { + var playerManager = cli.server.getPlayerManager(); + Collection players = playerManager.getPlayers(); + if (players.isEmpty()) { + cli.out.println("There are no players connected to the server."); + } else { + TablePrinter tp = new TablePrinter(cli.out) + .drawBorders(true) + .addLine("Id", "Username", "Health", "Position", "Held Item", "Team"); + players.stream().sorted(Comparator.comparing(Player::getId)).forEachOrdered(player -> { + tp.addLine( + player.getId(), + player.getUsername(), + String.format("%.2f / 1.00", player.getHealth()), + String.format("x=%.2f, y=%.2f, z=%.2f", player.getPosition().x, player.getPosition().y, player.getPosition().z), + player.getInventory().getSelectedItemStack().getType().getName(), + player.getTeam() == null ? "None" : player.getTeam().getName() + ); + }); + tp.println(); + } + } + + @CommandLine.Command(description = "Kicks a player from the server.") + public void kick(@CommandLine.Parameters(description = "The id or name of the player to kick.") String playerIdent) { + cli.server.getPlayerManager().findByIdOrName(playerIdent) + .ifPresentOrElse(player -> { + cli.server.getPlayerManager().deregister(player); + }, () -> cli.out.println("Player not found.")); + } +} diff --git a/server/src/main/java/nl/andrewl/aos2_server/cli/ServerCli.java b/server/src/main/java/nl/andrewl/aos2_server/cli/ServerCli.java new file mode 100644 index 0000000..1d87c95 --- /dev/null +++ b/server/src/main/java/nl/andrewl/aos2_server/cli/ServerCli.java @@ -0,0 +1,102 @@ +package nl.andrewl.aos2_server.cli; + +import nl.andrewl.aos2_server.Server; +import org.fusesource.jansi.AnsiConsole; +import org.jline.console.SystemRegistry; +import org.jline.console.impl.Builtins; +import org.jline.console.impl.SystemRegistryImpl; +import org.jline.keymap.KeyMap; +import org.jline.reader.*; +import org.jline.reader.impl.DefaultParser; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.widget.TailTipWidgets; +import picocli.CommandLine; +import picocli.shell.jline3.PicocliCommands; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Path; + +@CommandLine.Command( + name = "", + description = "Interactive shell for server commands.", + footer = {"", "Press Ctrl-D to exit."}, + subcommands = {StopCommand.class, PlayersCommand.class} +) +public class ServerCli implements Runnable { + final Server server; + PrintWriter out; + + public ServerCli(Server server) { + this.server = server; + } + + public void setReader(LineReader reader) { + this.out = reader.getTerminal().writer(); + } + + @Override + public void run() { + out.println(new CommandLine(this).getUsageMessage()); + } + + public static void start(Server server) { + AnsiConsole.systemInstall(); + Builtins builtins = new Builtins(Path.of("."), null, null); + builtins.rename(Builtins.Command.TTOP, "top"); + builtins.alias("zle", "widget"); + builtins.alias("bindkey", "keymap"); + + ServerCli baseCommand = new ServerCli(server); + PicocliCommands.PicocliCommandsFactory commandsFactory = new PicocliCommands.PicocliCommandsFactory(); + CommandLine cmd = new CommandLine(baseCommand, commandsFactory); + PicocliCommands picocliCommands = new PicocliCommands(cmd); + picocliCommands.commandDescription("bleh"); + Parser parser = new DefaultParser(); + try (Terminal terminal = TerminalBuilder.builder().build()) { + SystemRegistry systemRegistry = new SystemRegistryImpl(parser, terminal, () -> Path.of("."), null); + systemRegistry.setCommandRegistries(builtins, picocliCommands); + systemRegistry.register("help", picocliCommands); + + LineReader reader = LineReaderBuilder.builder() + .terminal(terminal) + .completer(systemRegistry.completer()) + .parser(parser) + .variable(LineReader.LIST_MAX, 50) + .build(); + builtins.setLineReader(reader); + baseCommand.setReader(reader); + commandsFactory.setTerminal(terminal); + TailTipWidgets widgets = new TailTipWidgets(reader, systemRegistry::commandDescription, 5, TailTipWidgets.TipType.COMPLETER); + widgets.enable(); + KeyMap keyMap = reader.getKeyMaps().get("main"); + keyMap.bind(new Reference("tailtip-toggle"), KeyMap.alt("s")); + + String prompt = "server> "; + String rightPrompt = "HI"; + + String line; + while (server.isRunning()) { + try { + systemRegistry.cleanUp(); + line = reader.readLine(prompt, rightPrompt, (MaskingCallback) null, null); + systemRegistry.execute(line); + } catch (UserInterruptException e) { + // Ignore + } catch (EndOfFileException e) { + break; + } catch (Exception e) { + systemRegistry.trace(e); + } + } + // If the user exits the CLI without calling "stop", we will shut down the server automatically. + if (server.isRunning()) { + server.shutdown(); + } + } catch (IOException e) { + e.printStackTrace(); + } + AnsiConsole.systemUninstall(); + } +} diff --git a/server/src/main/java/nl/andrewl/aos2_server/cli/StopCommand.java b/server/src/main/java/nl/andrewl/aos2_server/cli/StopCommand.java new file mode 100644 index 0000000..7a2d7aa --- /dev/null +++ b/server/src/main/java/nl/andrewl/aos2_server/cli/StopCommand.java @@ -0,0 +1,17 @@ +package nl.andrewl.aos2_server.cli; + +import picocli.CommandLine; + +@CommandLine.Command( + name = "stop", + description = "Stops the server.", + mixinStandardHelpOptions = true +) +public class StopCommand implements Runnable { + @CommandLine.ParentCommand ServerCli cli; + + @Override + public void run() { + cli.server.shutdown(); + } +} diff --git a/server/src/main/java/nl/andrewl/aos2_server/cli/TablePrinter.java b/server/src/main/java/nl/andrewl/aos2_server/cli/TablePrinter.java new file mode 100644 index 0000000..374cb3f --- /dev/null +++ b/server/src/main/java/nl/andrewl/aos2_server/cli/TablePrinter.java @@ -0,0 +1,136 @@ +package nl.andrewl.aos2_server.cli; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; + +public class TablePrinter { + private final PrintWriter out; + private final List> lines = new ArrayList<>(); + private boolean drawBorders = false; + private final int[] padding = new int[]{1, 1, 0, 0}; + + public TablePrinter(PrintWriter out) { + this.out = out; + } + + public TablePrinter addLine(List line) { + lines.add(line); + return this; + } + + public TablePrinter addLine(Object... objects) { + List line = new ArrayList<>(objects.length); + for (var obj : objects) { + line.add(obj.toString()); + } + return addLine(line); + } + + public TablePrinter drawBorders(boolean drawBorders) { + this.drawBorders = drawBorders; + return this; + } + + public TablePrinter padding(int left, int right, int top, int bottom) { + this.padding[0] = left; + this.padding[1] = right; + this.padding[2] = top; + this.padding[3] = bottom; + return this; + } + + public void println() { + int rowCount = lines.size(); + int colCount = lines.stream().mapToInt(List::size).max().orElse(0); + if (rowCount == 0 || colCount == 0) out.println(); + + int[] colSizes = new int[colCount]; + for (int i = 0; i < colCount; i++) { + final int columnIndex = i; + colSizes[i] = lines.stream().mapToInt(line -> { + if (columnIndex >= line.size()) return 0; + return line.get(columnIndex).length(); + }).max().orElse(0); + } + + for (int row = 0; row < rowCount; row++) { + // Row top border. + if (drawBorders) { + out.print('+'); + for (int col = 0; col < colCount; col++) { + out.print("-".repeat(colSizes[col] + leftPadding() + rightPadding())); + out.print('+'); + } + out.println(); + } + // Top padding rows. + for (int p = 0; p < topPadding(); p++) { + for (int col = 0; col < colCount; col++) { + if (drawBorders) out.print('|'); + out.print(" ".repeat(leftPadding() + colSizes[col] + rightPadding())); + } + if (drawBorders) out.print('|'); + out.println(); + } + // Column values. + for (int col = 0; col < colCount; col++) { + String value = getValueAt(row, col); + if (drawBorders) out.print('|'); + out.print(" ".repeat(leftPadding())); + out.print(value); + out.print(" ".repeat(colSizes[col] - value.length() + rightPadding())); + } + if (drawBorders) out.print('|'); + out.println(); + // Bottom padding rows. + for (int p = 0; p < bottomPadding(); p++) { + for (int col = 0; col < colCount; col++) { + if (drawBorders) out.print('|'); + out.print(" ".repeat(leftPadding() + colSizes[col] + rightPadding())); + } + if (drawBorders) out.print('|'); + out.println(); + } + + // Last row bottom border. + if (row == rowCount - 1 && drawBorders) { + out.print('+'); + for (int col = 0; col < colCount; col++) { + out.print("-".repeat(colSizes[col] + leftPadding() + rightPadding())); + out.print('+'); + } + out.println(); + } + } + } + + private String getValueAt(int row, int col) { + if (row < lines.size()) { + List line = lines.get(row); + if (col < line.size()) { + return line.get(col); + } else { + return ""; + } + } else { + return ""; + } + } + + private int leftPadding() { + return padding[0]; + } + + private int rightPadding() { + return padding[1]; + } + + private int topPadding() { + return padding[2]; + } + + private int bottomPadding() { + return padding[3]; + } +}