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];
+ }
+}