Compare commits
23 Commits
Author | SHA1 | Date |
---|---|---|
|
11be12d595 | |
|
0161bd5479 | |
|
89ec1ddccc | |
|
60d45a071f | |
|
38b01f0531 | |
|
782f32be8f | |
|
da5ab070a8 | |
|
4d7b01d4ae | |
|
19d6dc7a5e | |
|
99a1ef3441 | |
|
f7959796b4 | |
|
16b7a1e653 | |
|
359d1aa1b8 | |
|
8cfdf32bc0 | |
|
4db1ecd191 | |
|
c4a4479602 | |
|
c10dd7cd02 | |
|
e38ae65ff9 | |
|
a808ac1920 | |
|
5e462869a7 | |
|
9aeb5cd048 | |
|
20295596fa | |
|
352faff005 |
client
core
pom.xml
src/main/java/nl/andrewl/aos_core
launcher
icon.icoicon.pngpackage_linux.shpackage_windows.ps1pom.xml
pom.xmlsrc/main
java
module-info.java
nl/andrewl/aos2_launcher
resources
registry
.gitignore
.mvn/wrapper
README.mdbuild-native.shmvnwmvnw.cmdpom.xmlsrc
main
java/nl/andrewl/aos2registryapi
resources
test/java/nl/andrewl/aos2registryapi
server
pom.xml
src/main
java/nl/andrewl/aos2_server
ClientCommunicationHandler.javaPlayerManager.javaRegistryUpdater.javaServer.javaTeamManager.java
cli/ingame
config
model
resources
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<artifactId>ace-of-shades-2</artifactId>
|
||||
<groupId>nl.andrewl</groupId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package nl.andrewl.aos2_client;
|
||||
|
||||
import nl.andrewl.aos2_client.config.ClientConfig;
|
||||
import nl.andrewl.aos2_client.config.ConnectConfig;
|
||||
import nl.andrewl.aos2_client.control.InputHandler;
|
||||
import nl.andrewl.aos2_client.model.Chat;
|
||||
import nl.andrewl.aos2_client.model.ClientPlayer;
|
||||
|
@ -26,7 +27,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
public class Client implements Runnable {
|
||||
private final ClientConfig config;
|
||||
public final ConnectConfig connectConfig;
|
||||
public final ClientConfig config;
|
||||
|
||||
private final CommunicationHandler communicationHandler;
|
||||
private final InputHandler inputHandler;
|
||||
private final Camera camera;
|
||||
|
@ -42,8 +45,9 @@ public class Client implements Runnable {
|
|||
private final Chat chat;
|
||||
private final Queue<Runnable> mainThreadActions;
|
||||
|
||||
public Client(ClientConfig config) {
|
||||
public Client(ClientConfig config, ConnectConfig connectConfig) {
|
||||
this.config = config;
|
||||
this.connectConfig = connectConfig;
|
||||
this.camera = new Camera();
|
||||
this.players = new ConcurrentHashMap<>();
|
||||
this.teams = new ConcurrentHashMap<>();
|
||||
|
@ -156,6 +160,14 @@ public class Client implements Runnable {
|
|||
});
|
||||
} else if (msg instanceof PlayerLeaveMessage leaveMessage) {
|
||||
runLater(() -> players.remove(leaveMessage.id()));
|
||||
} else if (msg instanceof PlayerTeamUpdateMessage teamUpdateMessage) {
|
||||
runLater(() -> {
|
||||
OtherPlayer op = players.get(teamUpdateMessage.playerId());
|
||||
Team team = teamUpdateMessage.teamId() == -1 ? null : teams.get(teamUpdateMessage.teamId());
|
||||
if (op != null) {
|
||||
op.setTeam(team);
|
||||
}
|
||||
});
|
||||
} else if (msg instanceof SoundMessage soundMessage) {
|
||||
if (soundManager != null) {
|
||||
soundManager.play(
|
||||
|
@ -257,13 +269,22 @@ public class Client implements Runnable {
|
|||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
if (args.length < 3) {
|
||||
System.err.println("Missing required host, port, username args.");
|
||||
System.exit(1);
|
||||
}
|
||||
String host = args[0].trim();
|
||||
int port = Integer.parseInt(args[1]);
|
||||
String username = args[2].trim();
|
||||
ConnectConfig connectCfg = new ConnectConfig(host, port, username, false);
|
||||
|
||||
List<Path> configPaths = Config.getCommonConfigPaths();
|
||||
configPaths.add(0, Path.of("client.yaml")); // Add this first so we create client.yaml if needed.
|
||||
if (args.length > 0) {
|
||||
configPaths.add(Path.of(args[0].trim()));
|
||||
if (args.length > 3) {
|
||||
configPaths.add(Path.of(args[3].trim()));
|
||||
}
|
||||
ClientConfig clientConfig = Config.loadConfig(ClientConfig.class, configPaths, new ClientConfig(), "default-config.yaml");
|
||||
Client client = new Client(clientConfig);
|
||||
Client client = new Client(clientConfig, connectCfg);
|
||||
client.run();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@ import nl.andrewl.aos_core.model.Team;
|
|||
import nl.andrewl.aos_core.model.item.ItemStack;
|
||||
import nl.andrewl.aos_core.model.world.World;
|
||||
import nl.andrewl.aos_core.model.world.WorldIO;
|
||||
import nl.andrewl.aos_core.net.*;
|
||||
import nl.andrewl.aos_core.net.TcpReceiver;
|
||||
import nl.andrewl.aos_core.net.UdpReceiver;
|
||||
import nl.andrewl.aos_core.net.connect.ConnectAcceptMessage;
|
||||
import nl.andrewl.aos_core.net.connect.ConnectRejectMessage;
|
||||
import nl.andrewl.aos_core.net.connect.ConnectRequestMessage;
|
||||
|
@ -46,16 +47,14 @@ public class CommunicationHandler {
|
|||
if (socket != null && !socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
InetAddress address = InetAddress.getByName(client.getConfig().serverHost);
|
||||
int port = client.getConfig().serverPort;
|
||||
String username = client.getConfig().username;
|
||||
System.out.printf("Connecting to server at %s, port %d, with username \"%s\"...%n", address, port, username);
|
||||
InetAddress address = InetAddress.getByName(client.connectConfig.host());
|
||||
System.out.printf("Connecting to server at %s, port %d, with username \"%s\"...%n", address, client.connectConfig.port(), client.connectConfig.username());
|
||||
|
||||
socket = new Socket(address, port);
|
||||
socket = new Socket(address, client.connectConfig.port());
|
||||
socket.setSoTimeout(1000);
|
||||
in = Net.getInputStream(socket.getInputStream());
|
||||
out = Net.getOutputStream(socket.getOutputStream());
|
||||
Net.write(new ConnectRequestMessage(username), out);
|
||||
Net.write(new ConnectRequestMessage(client.connectConfig.username(), client.connectConfig.spectator()), out);
|
||||
Message response = Net.read(in);
|
||||
socket.setSoTimeout(0);
|
||||
if (response instanceof ConnectRejectMessage rejectMessage) {
|
||||
|
@ -63,7 +62,7 @@ public class CommunicationHandler {
|
|||
}
|
||||
if (response instanceof ConnectAcceptMessage acceptMessage) {
|
||||
this.clientId = acceptMessage.clientId();
|
||||
client.setMyPlayer(new ClientPlayer(clientId, username));
|
||||
client.setMyPlayer(new ClientPlayer(clientId, client.connectConfig.username()));
|
||||
receiveInitialData();
|
||||
establishDatagramConnection();
|
||||
new Thread(new TcpReceiver(in, client::onMessageReceived).withShutdownHook(this::shutdown)).start();
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
package nl.andrewl.aos2_client.config;
|
||||
|
||||
public class ClientConfig {
|
||||
public String serverHost = "localhost";
|
||||
public int serverPort = 25565;
|
||||
public String username = "player";
|
||||
public InputConfig input = new InputConfig();
|
||||
public DisplayConfig display = new DisplayConfig();
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package nl.andrewl.aos2_client.config;
|
||||
|
||||
/**
|
||||
* The data that's needed by the client to initially establish a connection.
|
||||
*/
|
||||
public record ConnectConfig(
|
||||
String host,
|
||||
int port,
|
||||
String username,
|
||||
boolean spectator
|
||||
) {}
|
|
@ -1,8 +1,4 @@
|
|||
# Ace of Shades 2 Client Configuration
|
||||
# Set these properties to connect to a server.
|
||||
serverHost: localhost
|
||||
serverPort: 25565
|
||||
username: player
|
||||
|
||||
# Settings for input.
|
||||
input:
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<artifactId>ace-of-shades-2</artifactId>
|
||||
<groupId>nl.andrewl</groupId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
|
|
@ -26,29 +26,37 @@ public final class Net {
|
|||
|
||||
private static final Serializer serializer = new Serializer();
|
||||
static {
|
||||
serializer.registerType(1, ConnectRequestMessage.class);
|
||||
serializer.registerType(2, ConnectAcceptMessage.class);
|
||||
serializer.registerType(3, ConnectRejectMessage.class);
|
||||
serializer.registerType(4, DatagramInit.class);
|
||||
serializer.registerType(5, ChunkHashMessage.class);
|
||||
serializer.registerType(6, ChunkDataMessage.class);
|
||||
serializer.registerType(7, ChunkUpdateMessage.class);
|
||||
serializer.registerType(8, ClientInputState.class);
|
||||
serializer.registerType(9, ClientOrientationState.class);
|
||||
serializer.registerType(10, PlayerUpdateMessage.class);
|
||||
serializer.registerType(11, PlayerJoinMessage.class);
|
||||
serializer.registerType(12, PlayerLeaveMessage.class);
|
||||
int i = 1;
|
||||
// Basic protocol messages.
|
||||
serializer.registerType(i++, ConnectRequestMessage.class);
|
||||
serializer.registerType(i++, ConnectAcceptMessage.class);
|
||||
serializer.registerType(i++, ConnectRejectMessage.class);
|
||||
serializer.registerType(i++, DatagramInit.class);
|
||||
|
||||
// World messages.
|
||||
serializer.registerType(i++, ChunkHashMessage.class);
|
||||
serializer.registerType(i++, ChunkDataMessage.class);
|
||||
serializer.registerType(i++, ChunkUpdateMessage.class);
|
||||
serializer.registerType(i++, ProjectileMessage.class);
|
||||
|
||||
// Player/client messages.
|
||||
serializer.registerType(i++, ClientInputState.class);
|
||||
serializer.registerType(i++, ClientOrientationState.class);
|
||||
serializer.registerType(i++, ClientHealthMessage.class);
|
||||
serializer.registerType(i++, PlayerUpdateMessage.class);
|
||||
serializer.registerType(i++, PlayerJoinMessage.class);
|
||||
serializer.registerType(i++, PlayerLeaveMessage.class);
|
||||
serializer.registerType(i++, PlayerTeamUpdateMessage.class);
|
||||
serializer.registerType(i++, BlockColorMessage.class);
|
||||
serializer.registerType(i++, InventorySelectedStackMessage.class);
|
||||
serializer.registerType(i++, ChatMessage.class);
|
||||
serializer.registerType(i++, ChatWrittenMessage.class);
|
||||
serializer.registerType(i++, ClientRecoilMessage.class);
|
||||
// Separate serializers for client inventory messages.
|
||||
serializer.registerTypeSerializer(13, new InventorySerializer());
|
||||
serializer.registerTypeSerializer(14, new ItemStackSerializer());
|
||||
serializer.registerType(15, InventorySelectedStackMessage.class);
|
||||
serializer.registerType(16, SoundMessage.class);
|
||||
serializer.registerType(17, ProjectileMessage.class);
|
||||
serializer.registerType(18, ClientHealthMessage.class);
|
||||
serializer.registerType(19, BlockColorMessage.class);
|
||||
serializer.registerType(20, ChatMessage.class);
|
||||
serializer.registerType(21, ChatWrittenMessage.class);
|
||||
serializer.registerType(22, ClientRecoilMessage.class);
|
||||
serializer.registerTypeSerializer(i++, new InventorySerializer());
|
||||
serializer.registerTypeSerializer(i++, new ItemStackSerializer());
|
||||
|
||||
serializer.registerType(i++, SoundMessage.class);
|
||||
}
|
||||
|
||||
public static ExtendedDataInputStream getInputStream(InputStream in) {
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package nl.andrewl.aos_core.net.client;
|
||||
|
||||
import nl.andrewl.record_net.Message;
|
||||
|
||||
/**
|
||||
* A message that's sent by the server to announce that a player has changed to
|
||||
* a specified team. Both the player and team should already be recognized by
|
||||
* all clients; otherwise they can ignore this.
|
||||
*/
|
||||
public record PlayerTeamUpdateMessage(
|
||||
int playerId,
|
||||
int teamId
|
||||
) implements Message {}
|
|
@ -2,4 +2,10 @@ package nl.andrewl.aos_core.net.connect;
|
|||
|
||||
import nl.andrewl.record_net.Message;
|
||||
|
||||
public record ConnectRequestMessage(String username) implements Message {}
|
||||
/**
|
||||
* The first message that a client sends via TCP to the server, to indicate
|
||||
* that they'd like to join.
|
||||
* @param username The player's chosen username.
|
||||
* @param spectator Whether the player wants to be a spectator.
|
||||
*/
|
||||
public record ConnectRequestMessage(String username, boolean spectator) implements Message {}
|
||||
|
|
Binary file not shown.
After (image error) Size: 31 KiB |
Binary file not shown.
After ![]() (image error) Size: 1.7 KiB |
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
function join_by {
|
||||
local d=${1-} f=${2-}
|
||||
if shift 2; then
|
||||
printf %s "$f" "${@/#/$d}"
|
||||
fi
|
||||
}
|
||||
|
||||
mvn clean package
|
||||
cd target
|
||||
module_jars=(lib/*)
|
||||
eligible_main_jars=("*.jar")
|
||||
main_jar=(${eligible_main_jars[0]})
|
||||
module_path=$(join_by ":" ${module_jars[@]})
|
||||
module_path="$main_jar:$module_path"
|
||||
echo $module_path
|
||||
jpackage \
|
||||
--name "Ace of Shades Launcher" \
|
||||
--app-version "1.0.0" \
|
||||
--description "Launcher app for Ace of Shades, a voxel-based first-person shooter." \
|
||||
--icon ../icon.ico \
|
||||
--linux-shortcut \
|
||||
--linux-deb-maintainer "andrewlalisofficial@gmail.com" \
|
||||
--linux-menu-group "Game" \
|
||||
--linux-app-category "Game" \
|
||||
--module-path "$module_path" \
|
||||
--module aos2_launcher/nl.andrewl.aos2_launcher.Launcher \
|
||||
--add-modules jdk.crypto.cryptoki
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# This script prepares and runs the jpackage command to generate a Windows AOS Client installer.
|
||||
|
||||
$projectDir = $PSScriptRoot
|
||||
Push-Location $projectDir\target
|
||||
|
||||
# Remove existing file if it exists.
|
||||
Write-Output "Removing existing exe file."
|
||||
Get-ChildItem *.exe | ForEach-Object { Remove-Item -Path $_.FullName -Force }
|
||||
Write-Output "Done."
|
||||
|
||||
# Run the build
|
||||
Write-Output "Building the project."
|
||||
Push-Location $projectDir
|
||||
mvn clean package
|
||||
|
||||
# Get list of dependency modules that maven copied into the lib directory.
|
||||
Push-Location $projectDir\target
|
||||
$modules = Get-ChildItem -Path lib -Name | ForEach-Object { "lib\$_" }
|
||||
# Add our own main module.
|
||||
$mainModuleJar = Get-ChildItem -Name -Include "aos2-launcher-*.jar" -Exclude "*-jar-with-dependencies.jar"
|
||||
$modules += $mainModuleJar
|
||||
Write-Output "Found modules: $modules"
|
||||
$modulePath = $modules -join ';'
|
||||
|
||||
Write-Output "Running jpackage..."
|
||||
jpackage `
|
||||
--type msi `
|
||||
--name "Ace-of-Shades" `
|
||||
--app-version "1.0.0" `
|
||||
--description "Top-down 2D shooter game inspired by Ace of Spades." `
|
||||
--icon ..\icon.ico `
|
||||
--win-shortcut `
|
||||
--win-dir-chooser `
|
||||
--win-per-user-install `
|
||||
--win-menu `
|
||||
--win-shortcut `
|
||||
--win-menu-group "Game" `
|
||||
--module-path "$modulePath" `
|
||||
--module aos2_launcher/nl.andrewl.aos2_launcher.Launcher `
|
||||
--add-modules jdk.crypto.cryptoki
|
||||
|
||||
Write-Output "Done!"
|
|
@ -11,8 +11,9 @@
|
|||
<properties>
|
||||
<maven.compiler.source>18</maven.compiler.source>
|
||||
<maven.compiler.target>18</maven.compiler.target>
|
||||
<javafx.version>18.0.1</javafx.version>
|
||||
<javafx.version>18.0.2</javafx.version>
|
||||
<javafx.maven.plugin.version>0.0.8</javafx.maven.plugin.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
@ -26,6 +27,12 @@
|
|||
<artifactId>javafx-fxml</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.9.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
@ -47,6 +54,46 @@
|
|||
<target>17</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>nl.andrewl.aos2_launcher.Launcher</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>make-assembly</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>single</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>2.8</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>copy-dependencies</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
|
|
@ -4,6 +4,10 @@ module aos2_launcher {
|
|||
requires javafx.graphics;
|
||||
requires javafx.fxml;
|
||||
|
||||
requires java.net.http;
|
||||
requires com.google.gson;
|
||||
|
||||
exports nl.andrewl.aos2_launcher to javafx.graphics;
|
||||
opens nl.andrewl.aos2_launcher to javafx.fxml;
|
||||
opens nl.andrewl.aos2_launcher.view to javafx.fxml;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
public class EditProfileController {
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.stage.Window;
|
||||
import nl.andrewl.aos2_launcher.model.Profile;
|
||||
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||
import nl.andrewl.aos2_launcher.model.Server;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class GameRunner {
|
||||
public void run(Profile profile, Server server, ProgressReporter progressReporter, Window owner) {
|
||||
SystemVersionValidator.getJreExecutablePath(progressReporter)
|
||||
.whenCompleteAsync((jrePath, throwable) -> {
|
||||
if (throwable != null) {
|
||||
showPopup(
|
||||
owner,
|
||||
Alert.AlertType.ERROR,
|
||||
"An error occurred while ensuring that you've got the latest Java runtime: " + throwable.getMessage()
|
||||
);
|
||||
} else {
|
||||
VersionFetcher.INSTANCE.ensureVersionIsDownloaded(profile.getClientVersion(), progressReporter)
|
||||
.whenCompleteAsync((clientJarPath, throwable2) -> {
|
||||
progressReporter.disableProgress();
|
||||
if (throwable2 != null) {
|
||||
showPopup(
|
||||
owner,
|
||||
Alert.AlertType.ERROR,
|
||||
"An error occurred while ensuring you've got the correct client version: " + throwable2.getMessage()
|
||||
);
|
||||
} else {
|
||||
startGame(owner, profile, server, jrePath, clientJarPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void startGame(Window owner, Profile profile, Server server, Path jrePath, Path clientJarPath) {
|
||||
try {
|
||||
Process p = new ProcessBuilder()
|
||||
.command(
|
||||
jrePath.toAbsolutePath().toString(),
|
||||
"-jar", clientJarPath.toAbsolutePath().toString(),
|
||||
server.getHost(),
|
||||
Integer.toString(server.getPort()),
|
||||
profile.getUsername()
|
||||
)
|
||||
.directory(profile.getDir().toFile())
|
||||
.inheritIO()
|
||||
.start();
|
||||
p.wait();
|
||||
} catch (IOException e) {
|
||||
showPopup(owner, Alert.AlertType.ERROR, "An error occurred while starting the game: " + e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
showPopup(owner, Alert.AlertType.ERROR, "The game was interrupted: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void showPopup(Window owner, Alert.AlertType type, String text) {
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.initOwner(owner);
|
||||
alert.setContentText(text);
|
||||
alert.show();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -7,22 +7,40 @@ import javafx.scene.Scene;
|
|||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The main starting point for the launcher app.
|
||||
*/
|
||||
public class Launcher extends Application {
|
||||
public static final Path BASE_DIR = Path.of(System.getProperty("user.home"), ".ace-of-shades");
|
||||
public static final Path VERSIONS_DIR = BASE_DIR.resolve("versions");
|
||||
public static final Path PROFILES_FILE = BASE_DIR.resolve("profiles.json");
|
||||
public static final Path PROFILES_DIR = BASE_DIR.resolve("profiles");
|
||||
public static final Path JRE_PATH = BASE_DIR.resolve("jre");
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) throws IOException {
|
||||
if (!Files.exists(BASE_DIR)) Files.createDirectory(BASE_DIR);
|
||||
if (!Files.exists(VERSIONS_DIR)) Files.createDirectory(VERSIONS_DIR);
|
||||
if (!Files.exists(PROFILES_DIR)) Files.createDirectory(PROFILES_DIR);
|
||||
FXMLLoader loader = new FXMLLoader(Launcher.class.getResource("/main_view.fxml"));
|
||||
Parent rootNode = loader.load();
|
||||
Scene scene = new Scene(rootNode);
|
||||
scene.getStylesheets().add(Launcher.class.getResource("/styles.css").toExternalForm());
|
||||
addStylesheet(scene, "/font/fonts.css");
|
||||
addStylesheet(scene, "/styles.css");
|
||||
stage.setScene(scene);
|
||||
stage.setTitle("Ace of Shades 2 - Launcher");
|
||||
stage.setTitle("Ace of Shades - Launcher");
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private void addStylesheet(Scene scene, String resource) throws IOException {
|
||||
var url = Launcher.class.getResource(resource);
|
||||
if (url == null) throw new IOException("Could not load resource at " + resource);
|
||||
scene.getStylesheets().add(url.toExternalForm());
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,140 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.layout.TilePane;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Window;
|
||||
import nl.andrewl.aos2_launcher.model.Profile;
|
||||
import nl.andrewl.aos2_launcher.model.ProfileSet;
|
||||
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||
import nl.andrewl.aos2_launcher.model.Server;
|
||||
import nl.andrewl.aos2_launcher.view.EditProfileDialog;
|
||||
import nl.andrewl.aos2_launcher.view.ElementList;
|
||||
import nl.andrewl.aos2_launcher.view.ProfileView;
|
||||
import nl.andrewl.aos2_launcher.view.ServerView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class MainViewController implements ProgressReporter {
|
||||
@FXML public Button playButton;
|
||||
@FXML public Button editProfileButton;
|
||||
@FXML public Button removeProfileButton;
|
||||
@FXML public VBox profilesVBox;
|
||||
private ElementList<Profile, ProfileView> profilesList;
|
||||
@FXML public VBox serversVBox;
|
||||
private ElementList<Server, ServerView> serversList;
|
||||
|
||||
@FXML public VBox progressVBox;
|
||||
@FXML public Label progressLabel;
|
||||
@FXML public ProgressBar progressBar;
|
||||
@FXML public TextField registryUrlField;
|
||||
|
||||
private final ProfileSet profileSet = new ProfileSet();
|
||||
|
||||
private ServersFetcher serversFetcher;
|
||||
|
||||
public class MainViewController {
|
||||
@FXML
|
||||
public TilePane profilesTilePane;
|
||||
public void initialize() {
|
||||
profilesList = new ElementList<>(profilesVBox, ProfileView::new, ProfileView.class, ProfileView::getProfile);
|
||||
profileSet.selectedProfileProperty().addListener((observable, oldValue, newValue) -> profileSet.save());
|
||||
// A hack since we can't bind the profilesList's elements to the profileSet's.
|
||||
profileSet.getProfiles().addListener((ListChangeListener<? super Profile>) c -> {
|
||||
var selected = profileSet.getSelectedProfile();
|
||||
profilesList.clear();
|
||||
profilesList.addAll(profileSet.getProfiles());
|
||||
profilesList.selectElement(selected);
|
||||
});
|
||||
profileSet.loadOrCreateStandardFile();
|
||||
profilesList.selectElement(profileSet.getSelectedProfile());
|
||||
profileSet.selectedProfileProperty().bind(profilesList.selectedElementProperty());
|
||||
|
||||
serversList = new ElementList<>(serversVBox, ServerView::new, ServerView.class, ServerView::getServer);
|
||||
|
||||
BooleanBinding playBind = profileSet.selectedProfileProperty().isNull().or(serversList.selectedElementProperty().isNull());
|
||||
playButton.disableProperty().bind(playBind);
|
||||
editProfileButton.disableProperty().bind(profileSet.selectedProfileProperty().isNull());
|
||||
removeProfileButton.disableProperty().bind(profileSet.selectedProfileProperty().isNull());
|
||||
|
||||
progressVBox.managedProperty().bind(progressVBox.visibleProperty());
|
||||
progressVBox.setVisible(false);
|
||||
|
||||
serversFetcher = new ServersFetcher(registryUrlField.textProperty());
|
||||
Platform.runLater(this::refreshServers);
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void refreshServers() {
|
||||
Window owner = this.profilesVBox.getScene().getWindow();
|
||||
serversFetcher.fetchServers(owner)
|
||||
.exceptionally(throwable -> {
|
||||
throwable.printStackTrace();
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setHeaderText("Couldn't fetch servers.");
|
||||
alert.setContentText("An error occurred, and the list of servers couldn't be fetched: " + throwable.getMessage() + ". Are you sure that you have the correct registry URL? Check the \"Servers\" tab.");
|
||||
alert.initOwner(owner);
|
||||
alert.show();
|
||||
});
|
||||
return new ArrayList<>();
|
||||
})
|
||||
.thenAccept(newServers -> Platform.runLater(() -> {
|
||||
serversList.clear();
|
||||
serversList.addAll(newServers);
|
||||
}));
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void addProfile() {
|
||||
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow());
|
||||
dialog.showAndWait().ifPresent(profileSet::addNewProfile);
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void editProfile() {
|
||||
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow(), profileSet.getSelectedProfile());
|
||||
dialog.showAndWait();
|
||||
profileSet.save();
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void removeProfile() {
|
||||
profileSet.removeSelectedProfile();
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void play() {
|
||||
new GameRunner().run(
|
||||
profileSet.getSelectedProfile(),
|
||||
serversList.getSelectedElement(),
|
||||
this,
|
||||
this.profilesVBox.getScene().getWindow()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableProgress() {
|
||||
Platform.runLater(() -> {
|
||||
progressVBox.setVisible(true);
|
||||
progressBar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
|
||||
progressLabel.setText(null);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableProgress() {
|
||||
Platform.runLater(() -> progressVBox.setVisible(false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setActionText(String text) {
|
||||
Platform.runLater(() -> progressLabel.setText(text));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProgress(double progress) {
|
||||
Platform.runLater(() -> progressBar.setProgress(progress));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.stage.Window;
|
||||
import nl.andrewl.aos2_launcher.model.Server;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
class ServersFetcher {
|
||||
private final HttpClient httpClient;
|
||||
private final Gson gson;
|
||||
private final StringProperty registryUrl;
|
||||
|
||||
public ServersFetcher(StringProperty registryUrlProperty) {
|
||||
httpClient = HttpClient.newBuilder().build();
|
||||
gson = new Gson();
|
||||
this.registryUrl = new SimpleStringProperty("http://localhost:8080");
|
||||
registryUrl.bind(registryUrlProperty);
|
||||
}
|
||||
|
||||
public CompletableFuture<List<Server>> fetchServers(Window owner) {
|
||||
if (registryUrl.get() == null || registryUrl.get().isBlank()) {
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING);
|
||||
alert.setContentText("Invalid or missing registry URL. Can't fetch the list of servers.");
|
||||
alert.initOwner(owner);
|
||||
alert.show();
|
||||
});
|
||||
return CompletableFuture.completedFuture(new ArrayList<>());
|
||||
}
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(registryUrl.get() + "/servers"))
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.header("Accept", "application/json")
|
||||
.build();
|
||||
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApplyAsync(resp -> {
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonArray serversArray = gson.fromJson(resp.body(), JsonArray.class);
|
||||
List<Server> servers = new ArrayList<>(serversArray.size());
|
||||
for (JsonElement serverJson : serversArray) {
|
||||
if (serverJson instanceof JsonObject obj) {
|
||||
servers.add(new Server(
|
||||
obj.get("host").getAsString(),
|
||||
obj.get("port").getAsInt(),
|
||||
obj.get("name").getAsString(),
|
||||
obj.get("description").getAsString(),
|
||||
obj.get("maxPlayers").getAsInt(),
|
||||
obj.get("currentPlayers").getAsInt(),
|
||||
obj.get("lastUpdatedAt").getAsLong()
|
||||
));
|
||||
}
|
||||
}
|
||||
return servers;
|
||||
} else {
|
||||
throw new RuntimeException("Invalid response: " + resp.statusCode());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||
import nl.andrewl.aos2_launcher.util.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public class SystemVersionValidator {
|
||||
private static final String os = System.getProperty("os.name").trim().toLowerCase();
|
||||
private static final String arch = System.getProperty("os.arch").trim().toLowerCase();
|
||||
|
||||
private static final boolean OS_WINDOWS = os.contains("win");
|
||||
private static final boolean OS_MAC = os.contains("mac");
|
||||
private static final boolean OS_LINUX = os.contains("nix") || os.contains("nux") || os.contains("aix");
|
||||
|
||||
private static final boolean ARCH_X86 = arch.equals("x86");
|
||||
private static final boolean ARCH_X86_64 = arch.equals("x86_64");
|
||||
private static final boolean ARCH_AMD64 = arch.equals("amd64");
|
||||
private static final boolean ARCH_AARCH64 = arch.equals("aarch64");
|
||||
private static final boolean ARCH_ARM = arch.equals("arm");
|
||||
private static final boolean ARCH_ARM32 = arch.equals("arm32");
|
||||
|
||||
private static final String JRE_DOWNLOAD_URL = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.4+8/";
|
||||
|
||||
public static String getPreferredVersionSuffix() {
|
||||
if (OS_LINUX) {
|
||||
if (ARCH_AARCH64) return "linux-aarch64";
|
||||
if (ARCH_AMD64) return "linux-amd64";
|
||||
if (ARCH_ARM) return "linux-arm";
|
||||
if (ARCH_ARM32) return "linux-arm32";
|
||||
} else if (OS_MAC) {
|
||||
if (ARCH_AARCH64) return "macos-aarch64";
|
||||
if (ARCH_X86_64) return "macos-x86_64";
|
||||
} else if (OS_WINDOWS) {
|
||||
if (ARCH_AARCH64) return "windows-aarch64";
|
||||
if (ARCH_AMD64) return "windows-amd64";
|
||||
if (ARCH_X86) return "windows-x86";
|
||||
}
|
||||
System.err.println("Couldn't determine the preferred OS/ARCH version. Defaulting to windows-amd64.");
|
||||
return "windows-amd64";
|
||||
}
|
||||
|
||||
public static CompletableFuture<Path> getJreExecutablePath(ProgressReporter progressReporter) {
|
||||
Optional<Path> optionalExecutablePath = findJreExecutable();
|
||||
return optionalExecutablePath.map(CompletableFuture::completedFuture)
|
||||
.orElseGet(() -> downloadAppropriateJre(progressReporter));
|
||||
}
|
||||
|
||||
public static CompletableFuture<Path> downloadAppropriateJre(ProgressReporter progressReporter) {
|
||||
progressReporter.enableProgress();
|
||||
progressReporter.setActionText("Downloading JRE...");
|
||||
HttpClient httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().GET().timeout(Duration.ofMinutes(5));
|
||||
String jreArchiveName = getPreferredJreName();
|
||||
String url = JRE_DOWNLOAD_URL + jreArchiveName;
|
||||
HttpRequest req = requestBuilder.uri(URI.create(url)).build();
|
||||
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApplyAsync(resp -> {
|
||||
if (resp.statusCode() == 200) {
|
||||
// Download sequentially, and update the progress.
|
||||
try {
|
||||
if (Files.exists(Launcher.JRE_PATH)) {
|
||||
FileUtils.deleteRecursive(Launcher.JRE_PATH);
|
||||
}
|
||||
Files.createDirectory(Launcher.JRE_PATH);
|
||||
Path jreArchiveFile = Launcher.JRE_PATH.resolve(jreArchiveName);
|
||||
FileUtils.downloadWithProgress(jreArchiveFile, resp, progressReporter);
|
||||
progressReporter.setProgress(-1); // Indefinite progress.
|
||||
progressReporter.setActionText("Unpacking JRE...");
|
||||
ProcessBuilder pb = new ProcessBuilder().inheritIO();
|
||||
if (OS_LINUX || OS_MAC) {
|
||||
pb.command("tar", "-xzf", jreArchiveFile.toAbsolutePath().toString(), "-C", Launcher.JRE_PATH.toAbsolutePath().toString());
|
||||
} else if (OS_WINDOWS) {
|
||||
pb.command("powershell", "-command", "\"Expand-Archive -Force '" + jreArchiveFile.toAbsolutePath() + "' '" + Launcher.JRE_PATH.toAbsolutePath() + "'\"");
|
||||
}
|
||||
Process process = pb.start();
|
||||
int result = process.waitFor();
|
||||
if (result != 0) throw new IOException("Archive extraction process exited with non-zero code: " + result);
|
||||
Files.delete(jreArchiveFile);
|
||||
progressReporter.setActionText("Looking for java executable...");
|
||||
Optional<Path> optionalExecutablePath = findJreExecutable();
|
||||
if (optionalExecutablePath.isEmpty()) throw new IOException("Couldn't find java executable.");
|
||||
progressReporter.disableProgress();
|
||||
return optionalExecutablePath.get();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("JRE download failed: " + resp.statusCode());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Optional<Path> findJreExecutable() {
|
||||
if (!Files.exists(Launcher.JRE_PATH)) return Optional.empty();
|
||||
BiPredicate<Path, BasicFileAttributes> pred = (path, basicFileAttributes) -> {
|
||||
String filename = path.getFileName().toString();
|
||||
return Files.isExecutable(path) && (filename.equals("java") || filename.equals("java.exe"));
|
||||
};
|
||||
try (var s = Files.find(Launcher.JRE_PATH, 3, pred)) {
|
||||
return s.findFirst();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static String getPreferredJreName() {
|
||||
if (OS_LINUX) {
|
||||
if (ARCH_AARCH64) return "OpenJDK17U-jre_aarch64_linux_hotspot_17.0.4_8.tar.gz";
|
||||
if (ARCH_AMD64) return "OpenJDK17U-jre_x64_linux_hotspot_17.0.4_8.tar.gz";
|
||||
if (ARCH_ARM || ARCH_ARM32) return "OpenJDK17U-jre_arm_linux_hotspot_17.0.4_8.tar.gz";
|
||||
} else if (OS_MAC) {
|
||||
if (ARCH_AARCH64) return "OpenJDK17U-jre_aarch64_mac_hotspot_17.0.4_8.tar.gz";
|
||||
if (ARCH_X86_64) return "OpenJDK17U-jre_x64_mac_hotspot_17.0.4_8.tar.gz";
|
||||
} else if (OS_WINDOWS) {
|
||||
if (ARCH_AARCH64 || ARCH_AMD64) return "OpenJDK17U-jre_x64_windows_hotspot_17.0.4_8.zip";
|
||||
if (ARCH_X86) return "OpenJDK17U-jre_x86-32_windows_hotspot_17.0.4_8.zip";
|
||||
}
|
||||
System.err.println("Couldn't determine the preferred JRE version. Defaulting to x64_windows.");
|
||||
return "OpenJDK17U-jre_x64_windows_hotspot_17.0.4_8.zip";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
package nl.andrewl.aos2_launcher;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import nl.andrewl.aos2_launcher.model.ClientVersionRelease;
|
||||
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||
import nl.andrewl.aos2_launcher.util.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class VersionFetcher {
|
||||
private static final String BASE_GITHUB_URL = "https://api.github.com/repos/andrewlalis/ace-of-shades-2";
|
||||
|
||||
public static final VersionFetcher INSTANCE = new VersionFetcher();
|
||||
|
||||
private final List<ClientVersionRelease> availableReleases;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||
private boolean loaded = false;
|
||||
private CompletableFuture<List<ClientVersionRelease>> activeReleaseFetchFuture;
|
||||
|
||||
public VersionFetcher() {
|
||||
this.availableReleases = new ArrayList<>();
|
||||
}
|
||||
|
||||
public CompletableFuture<ClientVersionRelease> getRelease(String versionTag) {
|
||||
return getAvailableReleases().thenApply(releases -> releases.stream()
|
||||
.filter(r -> r.tag().equals(versionTag))
|
||||
.findFirst().orElse(null));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<ClientVersionRelease>> getAvailableReleases() {
|
||||
if (loaded) {
|
||||
return CompletableFuture.completedFuture(Collections.unmodifiableList(availableReleases));
|
||||
}
|
||||
return fetchReleasesFromGitHub();
|
||||
}
|
||||
|
||||
private CompletableFuture<List<ClientVersionRelease>> fetchReleasesFromGitHub() {
|
||||
if (activeReleaseFetchFuture != null) return activeReleaseFetchFuture;
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE_GITHUB_URL + "/releases"))
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.GET()
|
||||
.build();
|
||||
activeReleaseFetchFuture = httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApplyAsync(resp -> {
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonArray releasesArray = new Gson().fromJson(new InputStreamReader(resp.body()), JsonArray.class);
|
||||
availableReleases.clear();
|
||||
for (var element : releasesArray) {
|
||||
if (element.isJsonObject()) {
|
||||
JsonObject obj = element.getAsJsonObject();
|
||||
String tag = obj.get("tag_name").getAsString();
|
||||
String apiUrl = obj.get("url").getAsString();
|
||||
String assetsUrl = obj.get("assets_url").getAsString();
|
||||
OffsetDateTime publishedAt = OffsetDateTime.parse(obj.get("published_at").getAsString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
|
||||
LocalDateTime localPublishedAt = publishedAt.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime();
|
||||
availableReleases.add(new ClientVersionRelease(tag, apiUrl, assetsUrl, localPublishedAt));
|
||||
}
|
||||
}
|
||||
availableReleases.sort(Comparator.comparing(ClientVersionRelease::publishedAt).reversed());
|
||||
loaded = true;
|
||||
return availableReleases;
|
||||
} else {
|
||||
throw new RuntimeException("Error while requesting releases.");
|
||||
}
|
||||
});
|
||||
return activeReleaseFetchFuture;
|
||||
}
|
||||
|
||||
public List<String> getDownloadedVersions() {
|
||||
try (var s = Files.list(Launcher.VERSIONS_DIR)) {
|
||||
return s.filter(this::isVersionFile)
|
||||
.map(this::extractVersion)
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<Path> ensureVersionIsDownloaded(String versionTag, ProgressReporter progressReporter) {
|
||||
try (var s = Files.list(Launcher.VERSIONS_DIR)) {
|
||||
Optional<Path> optionalFile = s.filter(f -> isVersionFile(f) && versionTag.equals(extractVersion(f)))
|
||||
.findFirst();
|
||||
if (optionalFile.isPresent()) return CompletableFuture.completedFuture(optionalFile.get());
|
||||
} catch (IOException e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
progressReporter.enableProgress();
|
||||
progressReporter.setActionText("Downloading client " + versionTag + "...");
|
||||
var future = getRelease(versionTag)
|
||||
.thenComposeAsync(release -> downloadVersion(release, progressReporter));
|
||||
future.thenRun(progressReporter::disableProgress);
|
||||
return future;
|
||||
}
|
||||
|
||||
private CompletableFuture<Path> downloadVersion(ClientVersionRelease release, ProgressReporter progressReporter) {
|
||||
System.out.println("Downloading version " + release.tag());
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(release.assetsUrl()))
|
||||
.GET().timeout(Duration.ofSeconds(3)).build();
|
||||
CompletableFuture<JsonObject> downloadUrlFuture = httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApplyAsync(resp -> {
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonArray assetsArray = new Gson().fromJson(new InputStreamReader(resp.body()), JsonArray.class);
|
||||
String preferredVersionSuffix = SystemVersionValidator.getPreferredVersionSuffix();
|
||||
String regex = "aos2-client-\\d+\\.\\d+\\.\\d+-" + preferredVersionSuffix + "\\.jar";
|
||||
for (var asset : assetsArray) {
|
||||
JsonObject assetObj = asset.getAsJsonObject();
|
||||
String name = assetObj.get("name").getAsString();
|
||||
if (name.matches(regex)) {
|
||||
return assetObj;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Couldn't find a matching release asset for this system.");
|
||||
} else {
|
||||
throw new RuntimeException("Error while requesting release assets from GitHub: " + resp.statusCode());
|
||||
}
|
||||
});
|
||||
return downloadUrlFuture.thenComposeAsync(asset -> {
|
||||
String url = asset.get("browser_download_url").getAsString();
|
||||
String fileName = asset.get("name").getAsString();
|
||||
HttpRequest downloadRequest = HttpRequest.newBuilder(URI.create(url))
|
||||
.GET().timeout(Duration.ofMinutes(5)).build();
|
||||
Path file = Launcher.VERSIONS_DIR.resolve(fileName);
|
||||
return httpClient.sendAsync(downloadRequest, HttpResponse.BodyHandlers.ofInputStream())
|
||||
.thenApplyAsync(resp -> {
|
||||
if (resp.statusCode() == 200) {
|
||||
// Download sequentially, and update the progress.
|
||||
try {
|
||||
FileUtils.downloadWithProgress(file, resp, progressReporter);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return file;
|
||||
} else {
|
||||
throw new RuntimeException("Error while downloading release asset from GitHub: " + resp.statusCode());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isVersionDownloaded(String versionTag) {
|
||||
return getDownloadedVersions().contains(versionTag);
|
||||
}
|
||||
|
||||
private boolean isVersionFile(Path p) {
|
||||
return Files.isRegularFile(p) && p.getFileName().toString()
|
||||
.matches("aos2-client-\\d+\\.\\d+\\.\\d+-.+\\.jar");
|
||||
}
|
||||
|
||||
private String extractVersion(Path file) {
|
||||
Pattern pattern = Pattern.compile("\\d+\\.\\d+\\.\\d+");
|
||||
Matcher matcher = pattern.matcher(file.getFileName().toString());
|
||||
if (matcher.find()) {
|
||||
return "v" + matcher.group();
|
||||
}
|
||||
throw new IllegalArgumentException("File doesn't contain a valid version pattern.");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package nl.andrewl.aos2_launcher.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
public record ClientVersionRelease (
|
||||
String tag,
|
||||
String apiUrl,
|
||||
String assetsUrl,
|
||||
LocalDateTime publishedAt
|
||||
) {}
|
|
@ -0,0 +1,84 @@
|
|||
package nl.andrewl.aos2_launcher.model;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import nl.andrewl.aos2_launcher.Launcher;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
public class Profile {
|
||||
private final UUID id;
|
||||
private final StringProperty name;
|
||||
private final StringProperty username;
|
||||
private final StringProperty clientVersion;
|
||||
private final StringProperty jvmArgs;
|
||||
|
||||
public Profile() {
|
||||
this(UUID.randomUUID(), "", "Player", null, null);
|
||||
}
|
||||
|
||||
public Profile(UUID id, String name, String username, String clientVersion, String jvmArgs) {
|
||||
this.id = id;
|
||||
this.name = new SimpleStringProperty(name);
|
||||
this.username = new SimpleStringProperty(username);
|
||||
this.clientVersion = new SimpleStringProperty(clientVersion);
|
||||
this.jvmArgs = new SimpleStringProperty(jvmArgs);
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
public StringProperty nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username.get();
|
||||
}
|
||||
|
||||
public StringProperty usernameProperty() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getClientVersion() {
|
||||
return clientVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty clientVersionProperty() {
|
||||
return clientVersion;
|
||||
}
|
||||
|
||||
public String getJvmArgs() {
|
||||
return jvmArgs.get();
|
||||
}
|
||||
|
||||
public StringProperty jvmArgsProperty() {
|
||||
return jvmArgs;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name.set(name);
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username.set(username);
|
||||
}
|
||||
|
||||
public void setClientVersion(String clientVersion) {
|
||||
this.clientVersion.set(clientVersion);
|
||||
}
|
||||
|
||||
public void setJvmArgs(String jvmArgs) {
|
||||
this.jvmArgs.set(jvmArgs);
|
||||
}
|
||||
|
||||
public Path getDir() {
|
||||
return Launcher.PROFILES_DIR.resolve(id.toString());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
package nl.andrewl.aos2_launcher.model;
|
||||
|
||||
import com.google.gson.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import nl.andrewl.aos2_launcher.Launcher;
|
||||
import nl.andrewl.aos2_launcher.util.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Model for managing the set of profiles in the app.
|
||||
*/
|
||||
public class ProfileSet {
|
||||
private final ObservableList<Profile> profiles;
|
||||
private final ObjectProperty<Profile> selectedProfile;
|
||||
private Path lastFileUsed = null;
|
||||
|
||||
public ProfileSet() {
|
||||
this.profiles = FXCollections.observableArrayList();
|
||||
this.selectedProfile = new SimpleObjectProperty<>(null);
|
||||
}
|
||||
|
||||
public ProfileSet(Path file) throws IOException {
|
||||
this();
|
||||
load(file);
|
||||
}
|
||||
|
||||
public void addNewProfile(Profile profile) {
|
||||
profiles.add(profile);
|
||||
save();
|
||||
try {
|
||||
if (!Files.exists(profile.getDir())) {
|
||||
Files.createDirectory(profile.getDir());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeProfile(Profile profile) {
|
||||
if (profile == null) return;
|
||||
boolean removed = profiles.remove(profile);
|
||||
if (removed) {
|
||||
try {
|
||||
if (Files.exists(profile.getDir())) {
|
||||
FileUtils.deleteRecursive(profile.getDir());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
public void removeSelectedProfile() {
|
||||
removeProfile(getSelectedProfile());
|
||||
}
|
||||
|
||||
public void load(Path file) throws IOException {
|
||||
try (var reader = Files.newBufferedReader(file)) {
|
||||
JsonObject data = new Gson().fromJson(reader, JsonObject.class);
|
||||
profiles.clear();
|
||||
JsonElement selectedProfileIdElement = data.get("selectedProfileId");
|
||||
UUID selectedProfileId = (selectedProfileIdElement == null || selectedProfileIdElement.isJsonNull())
|
||||
? null
|
||||
: UUID.fromString(selectedProfileIdElement.getAsString());
|
||||
JsonArray profilesArray = data.getAsJsonArray("profiles");
|
||||
for (JsonElement element : profilesArray) {
|
||||
JsonObject profileObj = element.getAsJsonObject();
|
||||
UUID id = UUID.fromString(profileObj.get("id").getAsString());
|
||||
String name = profileObj.get("name").getAsString();
|
||||
String clientVersion = profileObj.get("clientVersion").getAsString();
|
||||
String username = profileObj.get("username").getAsString();
|
||||
JsonElement jvmArgsElement = profileObj.get("jvmArgs");
|
||||
String jvmArgs = null;
|
||||
if (jvmArgsElement != null && jvmArgsElement.isJsonPrimitive() && jvmArgsElement.getAsJsonPrimitive().isString()) {
|
||||
jvmArgs = jvmArgsElement.getAsString();
|
||||
}
|
||||
Profile profile = new Profile(id, name, username, clientVersion, jvmArgs);
|
||||
profiles.add(profile);
|
||||
if (selectedProfileId != null && selectedProfileId.equals(profile.getId())) {
|
||||
selectedProfile.set(profile);
|
||||
}
|
||||
}
|
||||
lastFileUsed = file;
|
||||
}
|
||||
}
|
||||
|
||||
public void loadOrCreateStandardFile() {
|
||||
if (!Files.exists(Launcher.PROFILES_FILE)) {
|
||||
try {
|
||||
save(Launcher.PROFILES_FILE);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
load(Launcher.PROFILES_FILE);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void save(Path file) throws IOException {
|
||||
Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
JsonObject data = new JsonObject();
|
||||
String selectedProfileId = selectedProfile.getValue() == null ? null : selectedProfile.getValue().getId().toString();
|
||||
data.addProperty("selectedProfileId", selectedProfileId);
|
||||
JsonArray profilesArray = new JsonArray(profiles.size());
|
||||
for (Profile profile : profiles) {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("id", profile.getId().toString());
|
||||
obj.addProperty("name", profile.getName());
|
||||
obj.addProperty("username", profile.getUsername());
|
||||
obj.addProperty("clientVersion", profile.getClientVersion());
|
||||
obj.addProperty("jvmArgs", profile.getJvmArgs());
|
||||
profilesArray.add(obj);
|
||||
}
|
||||
data.add("profiles", profilesArray);
|
||||
try (var writer = Files.newBufferedWriter(file)) {
|
||||
gson.toJson(data, writer);
|
||||
}
|
||||
lastFileUsed = file;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
if (lastFileUsed != null) {
|
||||
try {
|
||||
save(lastFileUsed);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableList<Profile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public Profile getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Profile> selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewl.aos2_launcher.model;
|
||||
|
||||
public interface ProgressReporter {
|
||||
void enableProgress();
|
||||
void disableProgress();
|
||||
void setActionText(String text);
|
||||
void setProgress(double progress);
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package nl.andrewl.aos2_launcher.model;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
public class Server {
|
||||
private final StringProperty host;
|
||||
private final IntegerProperty port;
|
||||
private final StringProperty name;
|
||||
private final StringProperty description;
|
||||
private final IntegerProperty maxPlayers;
|
||||
private final IntegerProperty currentPlayers;
|
||||
private final ObjectProperty<LocalDateTime> lastUpdatedAt;
|
||||
|
||||
public Server(String host, int port, String name, String description, int maxPlayers, int currentPlayers, long lastUpdatedAt) {
|
||||
this.host = new SimpleStringProperty(host);
|
||||
this.port = new SimpleIntegerProperty(port);
|
||||
this.name = new SimpleStringProperty(name);
|
||||
this.description = new SimpleStringProperty(description);
|
||||
this.maxPlayers = new SimpleIntegerProperty(maxPlayers);
|
||||
this.currentPlayers = new SimpleIntegerProperty(currentPlayers);
|
||||
LocalDateTime ts = Instant.ofEpochMilli(lastUpdatedAt).atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
this.lastUpdatedAt = new SimpleObjectProperty<>(ts);
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
return host.get();
|
||||
}
|
||||
|
||||
public StringProperty hostProperty() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port.get();
|
||||
}
|
||||
|
||||
public IntegerProperty portProperty() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
public StringProperty nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description.get();
|
||||
}
|
||||
|
||||
public StringProperty descriptionProperty() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public int getMaxPlayers() {
|
||||
return maxPlayers.get();
|
||||
}
|
||||
|
||||
public IntegerProperty maxPlayersProperty() {
|
||||
return maxPlayers;
|
||||
}
|
||||
|
||||
public int getCurrentPlayers() {
|
||||
return currentPlayers.get();
|
||||
}
|
||||
|
||||
public IntegerProperty currentPlayersProperty() {
|
||||
return currentPlayers;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastUpdatedAt() {
|
||||
return lastUpdatedAt.get();
|
||||
}
|
||||
|
||||
public Property<LocalDateTime> lastUpdatedAtProperty() {
|
||||
return lastUpdatedAt;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package nl.andrewl.aos2_launcher.util;
|
||||
|
||||
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.text.CharacterIterator;
|
||||
import java.text.StringCharacterIterator;
|
||||
|
||||
public class FileUtils {
|
||||
public static String humanReadableByteCountSI(long bytes) {
|
||||
if (-1000 < bytes && bytes < 1000) {
|
||||
return bytes + " B";
|
||||
}
|
||||
CharacterIterator ci = new StringCharacterIterator("kMGTPE");
|
||||
while (bytes <= -999_950 || bytes >= 999_950) {
|
||||
bytes /= 1000;
|
||||
ci.next();
|
||||
}
|
||||
return String.format("%.1f %cB", bytes / 1000.0, ci.current());
|
||||
}
|
||||
|
||||
public static String humanReadableByteCountBin(long bytes) {
|
||||
long absB = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);
|
||||
if (absB < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
long value = absB;
|
||||
CharacterIterator ci = new StringCharacterIterator("KMGTPE");
|
||||
for (int i = 40; i >= 0 && absB > 0xfffccccccccccccL >> i; i -= 10) {
|
||||
value >>= 10;
|
||||
ci.next();
|
||||
}
|
||||
value *= Long.signum(bytes);
|
||||
return String.format("%.1f %ciB", value / 1024.0, ci.current());
|
||||
}
|
||||
|
||||
public static void deleteRecursive(Path p) throws IOException {
|
||||
Files.walkFileTree(p, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||
Files.delete(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void downloadWithProgress(Path outputFile, HttpResponse<InputStream> resp, ProgressReporter reporter) throws IOException {
|
||||
reporter.setProgress(0);
|
||||
long size = resp.headers().firstValueAsLong("Content-Length").orElse(1);
|
||||
try (var out = Files.newOutputStream(outputFile); var in = resp.body()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
long bytesRead = 0;
|
||||
while (bytesRead < size) {
|
||||
int readCount = in.read(buffer);
|
||||
out.write(buffer, 0, readCount);
|
||||
bytesRead += readCount;
|
||||
reporter.setProgress((double) bytesRead / size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package nl.andrewl.aos2_launcher.view;
|
||||
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
public class BindingUtil {
|
||||
public static <E, F> void mapContent(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||
Function<? super E, ? extends F> mapper) {
|
||||
map(mapped, source, mapper);
|
||||
}
|
||||
|
||||
private static <E, F> Object map(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||
Function<? super E, ? extends F> mapper) {
|
||||
final ListContentMapping<E, F> contentMapping = new ListContentMapping<>(mapped, mapper);
|
||||
mapped.setAll(source.stream().map(mapper).collect(toList()));
|
||||
source.removeListener(contentMapping);
|
||||
source.addListener(contentMapping);
|
||||
return contentMapping;
|
||||
}
|
||||
|
||||
private static class ListContentMapping<E, F> implements ListChangeListener<E>, WeakListener {
|
||||
private final WeakReference<List<F>> mappedRef;
|
||||
private final Function<? super E, ? extends F> mapper;
|
||||
|
||||
public ListContentMapping(List<F> mapped, Function<? super E, ? extends F> mapper) {
|
||||
this.mappedRef = new WeakReference<>(mapped);
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged(Change<? extends E> change) {
|
||||
final List<F> mapped = mappedRef.get();
|
||||
if (mapped == null) {
|
||||
change.getList().removeListener(this);
|
||||
} else {
|
||||
while (change.next()) {
|
||||
if (change.wasPermutated()) {
|
||||
mapped.subList(change.getFrom(), change.getTo()).clear();
|
||||
mapped.addAll(change.getFrom(), change.getList().subList(change.getFrom(), change.getTo())
|
||||
.stream().map(mapper).toList());
|
||||
} else {
|
||||
if (change.wasRemoved()) {
|
||||
mapped.subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear();
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
mapped.addAll(change.getFrom(), change.getAddedSubList()
|
||||
.stream().map(mapper).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return mappedRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
final List<F> list = mappedRef.get();
|
||||
return (list == null) ? 0 : list.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final List<F> mapped1 = mappedRef.get();
|
||||
if (mapped1 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (obj instanceof final ListContentMapping<?, ?> other) {
|
||||
final List<?> mapped2 = other.mappedRef.get();
|
||||
return mapped1 == mapped2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
package nl.andrewl.aos2_launcher.view;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Window;
|
||||
import nl.andrewl.aos2_launcher.VersionFetcher;
|
||||
import nl.andrewl.aos2_launcher.model.ClientVersionRelease;
|
||||
import nl.andrewl.aos2_launcher.model.Profile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class EditProfileDialog extends Dialog<Profile> {
|
||||
@FXML public TextField nameField;
|
||||
@FXML public TextField usernameField;
|
||||
@FXML public ChoiceBox<String> clientVersionChoiceBox;
|
||||
@FXML public TextArea jvmArgsTextArea;
|
||||
|
||||
private final ObjectProperty<Profile> profile;
|
||||
|
||||
public EditProfileDialog(Window owner, Profile profile) {
|
||||
this.profile = new SimpleObjectProperty<>(profile);
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(EditProfileDialog.class.getResource("/dialog/edit_profile.fxml"));
|
||||
loader.setController(this);
|
||||
Parent parent = loader.load();
|
||||
initOwner(owner);
|
||||
initModality(Modality.APPLICATION_MODAL);
|
||||
setResizable(true);
|
||||
setTitle("Edit Profile");
|
||||
|
||||
BooleanBinding formInvalid = nameField.textProperty().isEmpty()
|
||||
.or(clientVersionChoiceBox.valueProperty().isNull())
|
||||
.or(usernameField.textProperty().isEmpty());
|
||||
nameField.setText(profile.getName());
|
||||
usernameField.setText(profile.getUsername());
|
||||
VersionFetcher.INSTANCE.getAvailableReleases()
|
||||
.whenComplete((releases, throwable) -> Platform.runLater(() -> {
|
||||
if (throwable == null) {
|
||||
clientVersionChoiceBox.setItems(FXCollections.observableArrayList(releases.stream().map(ClientVersionRelease::tag).toList()));
|
||||
// If the profile doesn't have a set version, use the latest release.
|
||||
if (profile.getClientVersion() == null || profile.getClientVersion().isBlank()) {
|
||||
String lastRelease = releases.size() == 0 ? null : releases.get(0).tag();
|
||||
if (lastRelease != null) {
|
||||
clientVersionChoiceBox.setValue(lastRelease);
|
||||
}
|
||||
} else {
|
||||
clientVersionChoiceBox.setValue(profile.getClientVersion());
|
||||
}
|
||||
} else {
|
||||
throwable.printStackTrace();
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.initOwner(this.getOwner());
|
||||
alert.setContentText("An error occurred while fetching the latest game releases: " + throwable.getMessage());
|
||||
alert.show();
|
||||
}
|
||||
}));
|
||||
jvmArgsTextArea.setText(profile.getJvmArgs());
|
||||
|
||||
DialogPane pane = new DialogPane();
|
||||
pane.setContent(parent);
|
||||
ButtonType okButton = new ButtonType("Ok", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
pane.getButtonTypes().add(okButton);
|
||||
pane.getButtonTypes().add(cancelButton);
|
||||
pane.lookupButton(okButton).disableProperty().bind(formInvalid);
|
||||
setDialogPane(pane);
|
||||
setResultConverter(buttonType -> {
|
||||
if (!Objects.equals(ButtonBar.ButtonData.OK_DONE, buttonType.getButtonData())) {
|
||||
return null;
|
||||
}
|
||||
var prof = this.profile.getValue();
|
||||
prof.setName(nameField.getText().trim());
|
||||
prof.setUsername(usernameField.getText().trim());
|
||||
prof.setClientVersion(clientVersionChoiceBox.getValue());
|
||||
prof.setJvmArgs(jvmArgsTextArea.getText());
|
||||
return this.profile.getValue();
|
||||
});
|
||||
setOnShowing(event -> Platform.runLater(() -> nameField.requestFocus()));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public EditProfileDialog(Window owner) {
|
||||
this(owner, new Profile());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
package nl.andrewl.aos2_launcher.view;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class ElementList<T, V extends Node> {
|
||||
private final Pane container;
|
||||
|
||||
private final ObjectProperty<T> selectedElement = new SimpleObjectProperty<>(null);
|
||||
private final ObservableList<T> elements = FXCollections.observableArrayList();
|
||||
private final Class<V> elementViewType;
|
||||
private final Function<V, T> viewElementMapper;
|
||||
|
||||
public ElementList(
|
||||
Pane container,
|
||||
Function<T, V> elementViewMapper,
|
||||
Class<V> elementViewType,
|
||||
Function<V, T> viewElementMapper
|
||||
) {
|
||||
this.container = container;
|
||||
this.elementViewType = elementViewType;
|
||||
this.viewElementMapper = viewElementMapper;
|
||||
BindingUtil.mapContent(container.getChildren(), elements, element -> {
|
||||
V view = elementViewMapper.apply(element);
|
||||
view.getStyleClass().add("element-list-item");
|
||||
return view;
|
||||
});
|
||||
container.addEventHandler(MouseEvent.MOUSE_CLICKED, this::handleMouseClick);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleMouseClick(MouseEvent event) {
|
||||
Node target = (Node) event.getTarget();
|
||||
while (target != null) {
|
||||
if (target.getClass().equals(elementViewType)) {
|
||||
V elementView = (V) target;
|
||||
T targetElement = viewElementMapper.apply(elementView);
|
||||
if (event.isControlDown()) {
|
||||
if (selectedElement.get() == null) {
|
||||
selectElement(targetElement);
|
||||
} else {
|
||||
selectElement(null);
|
||||
}
|
||||
} else {
|
||||
selectElement(targetElement);
|
||||
}
|
||||
return; // Exit since we found a valid target.
|
||||
}
|
||||
target = target.getParent();
|
||||
}
|
||||
selectElement(null);
|
||||
}
|
||||
|
||||
public void selectElement(T element) {
|
||||
if (element != null && !elements.contains(element)) return;
|
||||
selectedElement.set(element);
|
||||
updateSelectedPseudoClass();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateSelectedPseudoClass() {
|
||||
PseudoClass selectedClass = PseudoClass.getPseudoClass("selected");
|
||||
for (var node : container.getChildren()) {
|
||||
if (!node.getClass().equals(elementViewType)) continue;
|
||||
V view = (V) node;
|
||||
T thisElement = viewElementMapper.apply(view);
|
||||
view.pseudoClassStateChanged(selectedClass, thisElement.equals(selectedElement.get()));
|
||||
}
|
||||
}
|
||||
|
||||
public T getSelectedElement() {
|
||||
return selectedElement.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<T> selectedElementProperty() {
|
||||
return selectedElement;
|
||||
}
|
||||
|
||||
public ObservableList<T> getElements() {
|
||||
return elements;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
elements.clear();
|
||||
selectElement(null);
|
||||
}
|
||||
|
||||
public void add(T element) {
|
||||
elements.add(element);
|
||||
}
|
||||
|
||||
public void addAll(Collection<T> newElements) {
|
||||
elements.addAll(newElements);
|
||||
}
|
||||
|
||||
public void remove(T element) {
|
||||
elements.remove(element);
|
||||
if (element != null && element.equals(selectedElement.get())) {
|
||||
selectElement(null);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package nl.andrewl.aos2_launcher.view;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Pane;
|
||||
import nl.andrewl.aos2_launcher.model.Profile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ProfileView extends Pane {
|
||||
private final Profile profile;
|
||||
|
||||
@FXML public Label nameLabel;
|
||||
@FXML public Label clientVersionLabel;
|
||||
@FXML public Label usernameLabel;
|
||||
|
||||
public ProfileView(Profile profile) {
|
||||
this.profile = profile;
|
||||
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(ProfileView.class.getResource("/profile_view.fxml"));
|
||||
loader.setController(this);
|
||||
Node node = loader.load();
|
||||
getChildren().add(node);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
nameLabel.textProperty().bind(profile.nameProperty());
|
||||
clientVersionLabel.textProperty().bind(profile.clientVersionProperty());
|
||||
usernameLabel.textProperty().bind(profile.usernameProperty());
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return this.profile;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package nl.andrewl.aos2_launcher.view;
|
||||
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
import nl.andrewl.aos2_launcher.model.Server;
|
||||
|
||||
public class ServerView extends VBox {
|
||||
private final Server server;
|
||||
|
||||
public ServerView(Server server) {
|
||||
this.server = server;
|
||||
var hostLabel = new Label();
|
||||
hostLabel.textProperty().bind(server.hostProperty());
|
||||
var portLabel = new Label();
|
||||
portLabel.setText(Integer.toString(server.getPort()));
|
||||
server.portProperty().addListener((observableValue, x1, x2) -> {
|
||||
portLabel.setText(x2.toString());
|
||||
});
|
||||
var nameLabel = new Label();
|
||||
nameLabel.textProperty().bind(server.nameProperty());
|
||||
var descriptionLabel = new Label();
|
||||
descriptionLabel.textProperty().bind(server.descriptionProperty());
|
||||
var playersLabel = new Label();
|
||||
|
||||
var nodes = getChildren();
|
||||
nodes.addAll(hostLabel, portLabel, nameLabel, descriptionLabel);
|
||||
getStyleClass().add("list-item");
|
||||
}
|
||||
|
||||
public Server getServer() {
|
||||
return server;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.*?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
|
||||
<AnchorPane minHeight="-Infinity" minWidth="-Infinity" prefHeight="300.0" prefWidth="400.0" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1">
|
||||
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" spacing="10.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
|
||||
<padding>
|
||||
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
|
||||
</padding>
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<Label text="Profile Name" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
<TextField fx:id="nameField" promptText="Enter a name for the profile..." AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="150.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
</AnchorPane>
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<Label text="Username" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
<TextField fx:id="usernameField" promptText="Enter a username..." AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="150.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
</AnchorPane>
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<Label text="Client Version" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
<ChoiceBox fx:id="clientVersionChoiceBox" prefWidth="150.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="150.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
</AnchorPane>
|
||||
<AnchorPane>
|
||||
<Label text="JVM Arguments" wrapText="true" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0"/>
|
||||
<TextArea fx:id="jvmArgsTextArea" prefHeight="100.0" prefWidth="200.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="150.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0"/>
|
||||
</AnchorPane>
|
||||
</VBox>
|
||||
</AnchorPane>
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,27 @@
|
|||
@font-face {
|
||||
src: url('JetBrainsMono-Regular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url('JetBrainsMono-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url('JetBrainsMono-Light.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url('JetBrainsMono-Italic.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url('JetBrainsMono-BoldItalic.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url('JetBrainsMono-LightItalic.ttf');
|
||||
}
|
||||
|
||||
.root {
|
||||
-fx-font-family: "JetBrains Mono";
|
||||
}
|
|
@ -1,24 +1,50 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.*?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.*?>
|
||||
|
||||
<VBox xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/16"
|
||||
maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0"
|
||||
fx:controller="nl.andrewl.aos2_launcher.MainViewController"
|
||||
>
|
||||
<MenuBar>
|
||||
<Menu mnemonicParsing="false" text="File">
|
||||
<MenuItem mnemonicParsing="false" text="Exit"/>
|
||||
</Menu>
|
||||
<Menu mnemonicParsing="false" text="Profiles">
|
||||
<MenuItem mnemonicParsing="false" text="New Profile"/>
|
||||
</Menu>
|
||||
<Menu mnemonicParsing="false" text="Help">
|
||||
<MenuItem mnemonicParsing="false" text="About"/>
|
||||
</Menu>
|
||||
</MenuBar>
|
||||
<ScrollPane VBox.vgrow="ALWAYS">
|
||||
<TilePane fx:id="profilesTilePane"/>
|
||||
</ScrollPane>
|
||||
<VBox minHeight="300.0" minWidth="300.0" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns:fx="http://javafx.com/fxml/1" fx:controller="nl.andrewl.aos2_launcher.MainViewController">
|
||||
<TabPane tabClosingPolicy="UNAVAILABLE" VBox.vgrow="ALWAYS">
|
||||
<Tab text="Profiles">
|
||||
<VBox>
|
||||
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||
<Button onAction="#addProfile" text="Add Profile" />
|
||||
<Button fx:id="editProfileButton" onAction="#editProfile" text="Edit Profile" />
|
||||
<Button fx:id="removeProfileButton" onAction="#removeProfile" text="Remove Profile" />
|
||||
</HBox>
|
||||
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="profilesVBox" styleClass="banner-list" />
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</Tab>
|
||||
<Tab text="Servers">
|
||||
<VBox>
|
||||
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||
<Button onAction="#refreshServers" text="Refresh" />
|
||||
<TextField fx:id="registryUrlField" prefWidth="300.0" promptText="Registry URL" text="http://localhost:8080" style="-fx-font-size: 10px;" />
|
||||
</HBox>
|
||||
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="serversVBox" styleClass="banner-list" />
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</Tab>
|
||||
</TabPane>
|
||||
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||
<Button fx:id="playButton" mnemonicParsing="false" onAction="#play" text="Play" />
|
||||
</HBox>
|
||||
<VBox fx:id="progressVBox" VBox.vgrow="NEVER">
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<padding>
|
||||
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
|
||||
</padding>
|
||||
<Label fx:id="progressLabel" text="Work in progress..." AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0">
|
||||
<font>
|
||||
<Font size="10.0" />
|
||||
</font>
|
||||
</Label>
|
||||
<ProgressBar fx:id="progressBar" prefWidth="200.0" progress="0.0" AnchorPane.bottomAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||
</AnchorPane>
|
||||
</VBox>
|
||||
</VBox>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane prefWidth="300.0" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1">
|
||||
<padding><Insets top="5" bottom="5" left="5" right="5"/></padding>
|
||||
<top>
|
||||
<Label fx:id="nameLabel" text="Profile Name" BorderPane.alignment="CENTER_LEFT" style="-fx-font-size: 16px; -fx-font-weight: bold;">
|
||||
<BorderPane.margin>
|
||||
<Insets bottom="5.0" />
|
||||
</BorderPane.margin>
|
||||
</Label>
|
||||
</top>
|
||||
<center>
|
||||
<VBox BorderPane.alignment="CENTER">
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<Label text="Client Version" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0"
|
||||
AnchorPane.topAnchor="0.0"/>
|
||||
<Label fx:id="clientVersionLabel" text="v1.0.0" textAlignment="RIGHT" AnchorPane.bottomAnchor="0.0"
|
||||
AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" style="-fx-font-weight: bold;"/>
|
||||
</AnchorPane>
|
||||
<AnchorPane VBox.vgrow="NEVER">
|
||||
<Label text="Username" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0"
|
||||
AnchorPane.topAnchor="0.0"/>
|
||||
<Label fx:id="usernameLabel" text="Player" textAlignment="RIGHT" AnchorPane.bottomAnchor="0.0"
|
||||
AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" style="-fx-font-weight: bold;"/>
|
||||
</AnchorPane>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
|
@ -0,0 +1,22 @@
|
|||
.test{
|
||||
-fx-background-color: blue;
|
||||
}
|
||||
|
||||
.button-bar {
|
||||
-fx-padding: 5 0 5 0;
|
||||
-fx-spacing: 5;
|
||||
-fx-font-weight: bold;
|
||||
-fx-font-size: 16px;
|
||||
}
|
||||
|
||||
.banner-list {
|
||||
-fx-spacing: 5;
|
||||
}
|
||||
|
||||
.element-list-item:selected {
|
||||
-fx-background-color: #e3e3e3;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
-fx-border-radius: 0;
|
||||
}
|
2
pom.xml
2
pom.xml
|
@ -7,7 +7,7 @@
|
|||
<groupId>nl.andrewl</groupId>
|
||||
<artifactId>ace-of-shades-2</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>1.3.0</version>
|
||||
<version>1.5.0</version>
|
||||
<modules>
|
||||
<module>core</module>
|
||||
<module>server</module>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
HELP.md
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
Binary file not shown.
|
@ -0,0 +1,2 @@
|
|||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
|
|
@ -0,0 +1,34 @@
|
|||
# Ace of Shades Server Registry
|
||||
The registry is a REST API that keeps track of any servers that have recently announced their status to it. Servers can periodically send a simple JSON object with metadata about the server (name, description, players, etc.) so that players can more easily search for a server to play on.
|
||||
|
||||
### Fetching
|
||||
Client/launcher applications that want to get a list of servers from the registry should send a GET request to the API's `/servers` endpoint.
|
||||
|
||||
The following array of servers is returned from GET requests to the API's `/servers` endpoint:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"host": "0:0:0:0:0:0:0:1",
|
||||
"port": 1234,
|
||||
"name": "Andrew's Server",
|
||||
"description": "A good server.",
|
||||
"maxPlayers": 32,
|
||||
"currentPlayers": 2,
|
||||
"lastUpdatedAt": 1659710488855
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Posting
|
||||
The following payload should be sent by servers to the API's `/servers` endpoint via POST:
|
||||
```json
|
||||
{
|
||||
"port": 1234,
|
||||
"token": "abc123",
|
||||
"name": "Andrew's Server",
|
||||
"description": "A good server.",
|
||||
"maxPlayers": 32,
|
||||
"currentPlayers": 2
|
||||
}
|
||||
```
|
||||
Note that this should only be done at most once per minute. Any more frequent, and you'll receive 429 Too-Many-Requests responses, and continued spam may permanently block your server.
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Put your GRAALVM location here.
|
||||
export GRAALVM_HOME=/home/andrew/Downloads/graalvm-ce-java17-22.2.0
|
||||
mvn -Pnative -DskipTests clean package
|
|
@ -0,0 +1,316 @@
|
|||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Maven Start Up Batch script
|
||||
#
|
||||
# Required ENV vars:
|
||||
# ------------------
|
||||
# JAVA_HOME - location of a JDK home dir
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# M2_HOME - location of maven2's installed home dir
|
||||
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
# e.g. to debug Maven itself, use
|
||||
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
|
||||
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if [ -z "$MAVEN_SKIP_RC" ] ; then
|
||||
|
||||
if [ -f /usr/local/etc/mavenrc ] ; then
|
||||
. /usr/local/etc/mavenrc
|
||||
fi
|
||||
|
||||
if [ -f /etc/mavenrc ] ; then
|
||||
. /etc/mavenrc
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.mavenrc" ] ; then
|
||||
. "$HOME/.mavenrc"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# OS specific support. $var _must_ be set to either true or false.
|
||||
cygwin=false;
|
||||
darwin=false;
|
||||
mingw=false
|
||||
case "`uname`" in
|
||||
CYGWIN*) cygwin=true ;;
|
||||
MINGW*) mingw=true;;
|
||||
Darwin*) darwin=true
|
||||
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
|
||||
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
if [ -x "/usr/libexec/java_home" ]; then
|
||||
export JAVA_HOME="`/usr/libexec/java_home`"
|
||||
else
|
||||
export JAVA_HOME="/Library/Java/Home"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$JAVA_HOME" ] ; then
|
||||
if [ -r /etc/gentoo-release ] ; then
|
||||
JAVA_HOME=`java-config --jre-home`
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$M2_HOME" ] ; then
|
||||
## resolve links - $0 may be a link to maven's home
|
||||
PRG="$0"
|
||||
|
||||
# need this for relative symlinks
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG="`dirname "$PRG"`/$link"
|
||||
fi
|
||||
done
|
||||
|
||||
saveddir=`pwd`
|
||||
|
||||
M2_HOME=`dirname "$PRG"`/..
|
||||
|
||||
# make it fully qualified
|
||||
M2_HOME=`cd "$M2_HOME" && pwd`
|
||||
|
||||
cd "$saveddir"
|
||||
# echo Using m2 at $M2_HOME
|
||||
fi
|
||||
|
||||
# For Cygwin, ensure paths are in UNIX format before anything is touched
|
||||
if $cygwin ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --unix "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
|
||||
fi
|
||||
|
||||
# For Mingw, ensure paths are in UNIX format before anything is touched
|
||||
if $mingw ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME="`(cd "$M2_HOME"; pwd)`"
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
|
||||
fi
|
||||
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
javaExecutable="`which javac`"
|
||||
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
|
||||
# readlink(1) is not available as standard on Solaris 10.
|
||||
readLink=`which readlink`
|
||||
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
|
||||
if $darwin ; then
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
|
||||
else
|
||||
javaExecutable="`readlink -f \"$javaExecutable\"`"
|
||||
fi
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
|
||||
JAVA_HOME="$javaHome"
|
||||
export JAVA_HOME
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$JAVACMD" ] ; then
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
else
|
||||
JAVACMD="`\\unset -f command; \\command -v java`"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
echo "Error: JAVA_HOME is not defined correctly." >&2
|
||||
echo " We cannot execute $JAVACMD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$JAVA_HOME" ] ; then
|
||||
echo "Warning: JAVA_HOME environment variable is not set."
|
||||
fi
|
||||
|
||||
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
|
||||
|
||||
# traverses directory structure from process work directory to filesystem root
|
||||
# first directory with .mvn subdirectory is considered project base directory
|
||||
find_maven_basedir() {
|
||||
|
||||
if [ -z "$1" ]
|
||||
then
|
||||
echo "Path not specified to find_maven_basedir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
basedir="$1"
|
||||
wdir="$1"
|
||||
while [ "$wdir" != '/' ] ; do
|
||||
if [ -d "$wdir"/.mvn ] ; then
|
||||
basedir=$wdir
|
||||
break
|
||||
fi
|
||||
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
|
||||
if [ -d "${wdir}" ]; then
|
||||
wdir=`cd "$wdir/.."; pwd`
|
||||
fi
|
||||
# end of workaround
|
||||
done
|
||||
echo "${basedir}"
|
||||
}
|
||||
|
||||
# concatenates all lines of a file
|
||||
concat_lines() {
|
||||
if [ -f "$1" ]; then
|
||||
echo "$(tr -s '\n' ' ' < "$1")"
|
||||
fi
|
||||
}
|
||||
|
||||
BASE_DIR=`find_maven_basedir "$(pwd)"`
|
||||
if [ -z "$BASE_DIR" ]; then
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
##########################################################################################
|
||||
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
# This allows using the maven wrapper in projects that prohibit checking in binary data.
|
||||
##########################################################################################
|
||||
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found .mvn/wrapper/maven-wrapper.jar"
|
||||
fi
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
|
||||
fi
|
||||
if [ -n "$MVNW_REPOURL" ]; then
|
||||
jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
else
|
||||
jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
fi
|
||||
while IFS="=" read key value; do
|
||||
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
|
||||
esac
|
||||
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Downloading from: $jarUrl"
|
||||
fi
|
||||
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
|
||||
if $cygwin; then
|
||||
wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
|
||||
fi
|
||||
|
||||
if command -v wget > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found wget ... using wget"
|
||||
fi
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
else
|
||||
wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
|
||||
fi
|
||||
elif command -v curl > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found curl ... using curl"
|
||||
fi
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
curl -o "$wrapperJarPath" "$jarUrl" -f
|
||||
else
|
||||
curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
|
||||
fi
|
||||
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Falling back to using Java to download"
|
||||
fi
|
||||
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
|
||||
# For Cygwin, switch paths to Windows format before running javac
|
||||
if $cygwin; then
|
||||
javaClass=`cygpath --path --windows "$javaClass"`
|
||||
fi
|
||||
if [ -e "$javaClass" ]; then
|
||||
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Compiling MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
# Compiling the Java class
|
||||
("$JAVA_HOME/bin/javac" "$javaClass")
|
||||
fi
|
||||
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
# Running the downloader
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Running MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
##########################################################################################
|
||||
# End of extension
|
||||
##########################################################################################
|
||||
|
||||
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo $MAVEN_PROJECTBASEDIR
|
||||
fi
|
||||
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --path --windows "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
|
||||
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
|
||||
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
|
||||
fi
|
||||
|
||||
# Provide a "standardized" way to retrieve the CLI args that will
|
||||
# work with both Windows and non-Windows executions.
|
||||
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
|
||||
export MAVEN_CMD_LINE_ARGS
|
||||
|
||||
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
exec "$JAVACMD" \
|
||||
$MAVEN_OPTS \
|
||||
$MAVEN_DEBUG_OPTS \
|
||||
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
|
||||
"-Dmaven.home=${M2_HOME}" \
|
||||
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
|
||||
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
|
|
@ -0,0 +1,188 @@
|
|||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM https://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Maven Start Up Batch script
|
||||
@REM
|
||||
@REM Required ENV vars:
|
||||
@REM JAVA_HOME - location of a JDK home dir
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM M2_HOME - location of maven2's installed home dir
|
||||
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
|
||||
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
|
||||
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
@REM e.g. to debug Maven itself, use
|
||||
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
|
||||
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
|
||||
@echo off
|
||||
@REM set title of command window
|
||||
title %0
|
||||
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
|
||||
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
|
||||
|
||||
@REM set %HOME% to equivalent of $HOME
|
||||
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
|
||||
|
||||
@REM Execute a user defined script before this one
|
||||
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
|
||||
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
|
||||
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
|
||||
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
|
||||
:skipRcPre
|
||||
|
||||
@setlocal
|
||||
|
||||
set ERROR_CODE=0
|
||||
|
||||
@REM To isolate internal variables from possible post scripts, we use another setlocal
|
||||
@setlocal
|
||||
|
||||
@REM ==== START VALIDATION ====
|
||||
if not "%JAVA_HOME%" == "" goto OkJHome
|
||||
|
||||
echo.
|
||||
echo Error: JAVA_HOME not found in your environment. >&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the >&2
|
||||
echo location of your Java installation. >&2
|
||||
echo.
|
||||
goto error
|
||||
|
||||
:OkJHome
|
||||
if exist "%JAVA_HOME%\bin\java.exe" goto init
|
||||
|
||||
echo.
|
||||
echo Error: JAVA_HOME is set to an invalid directory. >&2
|
||||
echo JAVA_HOME = "%JAVA_HOME%" >&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the >&2
|
||||
echo location of your Java installation. >&2
|
||||
echo.
|
||||
goto error
|
||||
|
||||
@REM ==== END VALIDATION ====
|
||||
|
||||
:init
|
||||
|
||||
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
|
||||
@REM Fallback to current working directory if not found.
|
||||
|
||||
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
|
||||
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
|
||||
|
||||
set EXEC_DIR=%CD%
|
||||
set WDIR=%EXEC_DIR%
|
||||
:findBaseDir
|
||||
IF EXIST "%WDIR%"\.mvn goto baseDirFound
|
||||
cd ..
|
||||
IF "%WDIR%"=="%CD%" goto baseDirNotFound
|
||||
set WDIR=%CD%
|
||||
goto findBaseDir
|
||||
|
||||
:baseDirFound
|
||||
set MAVEN_PROJECTBASEDIR=%WDIR%
|
||||
cd "%EXEC_DIR%"
|
||||
goto endDetectBaseDir
|
||||
|
||||
:baseDirNotFound
|
||||
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
|
||||
cd "%EXEC_DIR%"
|
||||
|
||||
:endDetectBaseDir
|
||||
|
||||
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
|
||||
|
||||
@setlocal EnableExtensions EnableDelayedExpansion
|
||||
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
|
||||
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
|
||||
|
||||
:endReadAdditionalConfig
|
||||
|
||||
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
|
||||
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
|
||||
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
|
||||
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
|
||||
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
|
||||
)
|
||||
|
||||
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
|
||||
if exist %WRAPPER_JAR% (
|
||||
if "%MVNW_VERBOSE%" == "true" (
|
||||
echo Found %WRAPPER_JAR%
|
||||
)
|
||||
) else (
|
||||
if not "%MVNW_REPOURL%" == "" (
|
||||
SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
|
||||
)
|
||||
if "%MVNW_VERBOSE%" == "true" (
|
||||
echo Couldn't find %WRAPPER_JAR%, downloading it ...
|
||||
echo Downloading from: %DOWNLOAD_URL%
|
||||
)
|
||||
|
||||
powershell -Command "&{"^
|
||||
"$webclient = new-object System.Net.WebClient;"^
|
||||
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
|
||||
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
|
||||
"}"^
|
||||
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
|
||||
"}"
|
||||
if "%MVNW_VERBOSE%" == "true" (
|
||||
echo Finished downloading %WRAPPER_JAR%
|
||||
)
|
||||
)
|
||||
@REM End of extension
|
||||
|
||||
@REM Provide a "standardized" way to retrieve the CLI args that will
|
||||
@REM work with both Windows and non-Windows executions.
|
||||
set MAVEN_CMD_LINE_ARGS=%*
|
||||
|
||||
%MAVEN_JAVA_EXE% ^
|
||||
%JVM_CONFIG_MAVEN_PROPS% ^
|
||||
%MAVEN_OPTS% ^
|
||||
%MAVEN_DEBUG_OPTS% ^
|
||||
-classpath %WRAPPER_JAR% ^
|
||||
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
|
||||
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
|
||||
if ERRORLEVEL 1 goto error
|
||||
goto end
|
||||
|
||||
:error
|
||||
set ERROR_CODE=1
|
||||
|
||||
:end
|
||||
@endlocal & set ERROR_CODE=%ERROR_CODE%
|
||||
|
||||
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
|
||||
@REM check for post script, once with legacy .bat ending and once with .cmd ending
|
||||
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
|
||||
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
|
||||
:skipRcPost
|
||||
|
||||
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
|
||||
if "%MAVEN_BATCH_PAUSE%"=="on" pause
|
||||
|
||||
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
|
||||
|
||||
cmd /C exit /B %ERROR_CODE%
|
|
@ -0,0 +1,150 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.2</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>nl.andrewl</groupId>
|
||||
<artifactId>aos2-registry-api</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>aos2-registry-api</name>
|
||||
<description>Registry API for Ace of Shades 2 servers.</description>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<repackage.classifier/>
|
||||
<spring-native.version>0.12.1</spring-native.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>spring-native</artifactId>
|
||||
<version>${spring-native.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<classifier>${repackage.classifier}</classifier>
|
||||
<image>
|
||||
<builder>paketobuildpacks/builder:tiny</builder>
|
||||
<env>
|
||||
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
|
||||
</env>
|
||||
</image>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.experimental</groupId>
|
||||
<artifactId>spring-aot-maven-plugin</artifactId>
|
||||
<version>${spring-native.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>test-generate</id>
|
||||
<goals>
|
||||
<goal>test-generate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>generate</id>
|
||||
<goals>
|
||||
<goal>generate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>spring-releases</id>
|
||||
<name>Spring Releases</name>
|
||||
<url>https://repo.spring.io/release</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>spring-releases</id>
|
||||
<name>Spring Releases</name>
|
||||
<url>https://repo.spring.io/release</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<properties>
|
||||
<repackage.classifier>exec</repackage.classifier>
|
||||
<native-buildtools.version>0.9.13</native-buildtools.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.platform</groupId>
|
||||
<artifactId>junit-platform-launcher</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<version>${native-buildtools.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>test-native</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>test</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>build-native</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>build</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
|
@ -0,0 +1,15 @@
|
|||
package nl.andrewl.aos2registryapi;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class Aos2RegistryApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Aos2RegistryApiApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package nl.andrewl.aos2registryapi;
|
||||
|
||||
import nl.andrewl.aos2registryapi.dto.ServerInfoPayload;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class ServerInfoValidator {
|
||||
|
||||
public boolean validateName(String name) {
|
||||
return name != null && !name.isBlank() && name.length() <= 64;
|
||||
}
|
||||
|
||||
public boolean validateDescription(String description) {
|
||||
return description == null ||
|
||||
(!description.isBlank() && description.length() <= 256);
|
||||
}
|
||||
|
||||
public boolean validatePlayerCounts(int max, int current) {
|
||||
return max > 0 && current >= 0 && current <= max && max < 1000;
|
||||
}
|
||||
|
||||
public Optional<List<String>> validatePayload(ServerInfoPayload payload) {
|
||||
List<String> messages = new ArrayList<>(3);
|
||||
if (payload.port() < 0 || payload.port() > 65535) messages.add("Invalid port.");
|
||||
if (!validateName(payload.name())) messages.add("Invalid name.");
|
||||
if (!validateDescription(payload.description())) messages.add("Invalid description.");
|
||||
if (!validatePlayerCounts(payload.maxPlayers(), payload.currentPlayers())) messages.add("Invalid player counts.");
|
||||
if (messages.size() > 0) return Optional.of(messages);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package nl.andrewl.aos2registryapi;
|
||||
|
||||
import nl.andrewl.aos2registryapi.dto.ServerInfoPayload;
|
||||
import nl.andrewl.aos2registryapi.dto.ServerInfoResponse;
|
||||
import nl.andrewl.aos2registryapi.model.ServerIdentifier;
|
||||
import nl.andrewl.aos2registryapi.model.ServerInfo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Component
|
||||
public class ServerRegistry {
|
||||
private static final Logger log = LoggerFactory.getLogger(ServerRegistry.class);
|
||||
|
||||
public static final Duration SERVER_TIMEOUT = Duration.ofMinutes(3);
|
||||
public static final Duration SERVER_MIN_UPDATE = Duration.ofSeconds(5);
|
||||
|
||||
private final Map<ServerIdentifier, ServerInfo> servers = new ConcurrentHashMap<>();
|
||||
private final ServerInfoValidator infoValidator = new ServerInfoValidator();
|
||||
|
||||
public Flux<ServerInfoResponse> getServers() {
|
||||
Stream<ServerInfoResponse> stream = servers.entrySet().stream()
|
||||
.sorted(Comparator.comparing(entry -> entry.getValue().getLastUpdatedAt()))
|
||||
.map(entry -> new ServerInfoResponse(
|
||||
entry.getKey().host(),
|
||||
entry.getKey().port(),
|
||||
entry.getValue().getName(),
|
||||
entry.getValue().getDescription(),
|
||||
entry.getValue().getMaxPlayers(),
|
||||
entry.getValue().getCurrentPlayers(),
|
||||
entry.getValue().getLastUpdatedAt().toEpochMilli()
|
||||
));
|
||||
return Flux.fromStream(stream);
|
||||
}
|
||||
|
||||
public void acceptInfo(ServerIdentifier ident, ServerInfoPayload payload) {
|
||||
var result = infoValidator.validatePayload(payload);
|
||||
if (result.isPresent()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join(" ", result.get()));
|
||||
}
|
||||
ServerInfo info = servers.get(ident);
|
||||
if (info != null) {
|
||||
Instant now = Instant.now();
|
||||
// Check if this update was sent too fast.
|
||||
if (Duration.between(info.getLastUpdatedAt(), now).compareTo(SERVER_MIN_UPDATE) < 0) {
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Server update rate limit exceeded.");
|
||||
}
|
||||
// Update existing server.
|
||||
info.setName(payload.name());
|
||||
info.setDescription(payload.description());
|
||||
info.setMaxPlayers(payload.maxPlayers());
|
||||
info.setCurrentPlayers(payload.currentPlayers());
|
||||
info.setLastUpdatedAt(now);
|
||||
} else {
|
||||
// Save new server.
|
||||
servers.put(ident, new ServerInfo(payload.name(), payload.description(), payload.maxPlayers(), payload.currentPlayers()));
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES, initialDelay = 1)
|
||||
public void purgeOldServers() {
|
||||
Queue<ServerIdentifier> removalQueue = new LinkedList<>();
|
||||
final Instant cutoff = Instant.now().minus(SERVER_TIMEOUT);
|
||||
for (var entry : servers.entrySet()) {
|
||||
var ident = entry.getKey();
|
||||
var server = entry.getValue();
|
||||
if (server.getLastUpdatedAt().isBefore(cutoff)) {
|
||||
removalQueue.add(ident);
|
||||
}
|
||||
}
|
||||
while (!removalQueue.isEmpty()) {
|
||||
servers.remove(removalQueue.remove());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package nl.andrewl.aos2registryapi.api;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ErrorAdvice {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<?> handleRSE(ResponseStatusException e) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("code", e.getRawStatusCode());
|
||||
data.put("message", e.getReason());
|
||||
return ResponseEntity.status(e.getStatus()).body(data);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package nl.andrewl.aos2registryapi.api;
|
||||
|
||||
import nl.andrewl.aos2registryapi.ServerRegistry;
|
||||
import nl.andrewl.aos2registryapi.dto.ServerInfoPayload;
|
||||
import nl.andrewl.aos2registryapi.dto.ServerInfoResponse;
|
||||
import nl.andrewl.aos2registryapi.model.ServerIdentifier;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(path = "/servers")
|
||||
public class ServersController {
|
||||
private final ServerRegistry serverRegistry;
|
||||
|
||||
public ServersController(ServerRegistry serverRegistry) {
|
||||
this.serverRegistry = serverRegistry;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Flux<ServerInfoResponse> getServers() {
|
||||
return serverRegistry.getServers();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<Object>> updateServer(ServerHttpRequest req, @RequestBody Mono<ServerInfoPayload> payloadMono) {
|
||||
String host = req.getRemoteAddress().getAddress().getHostAddress();
|
||||
return payloadMono.mapNotNull(payload -> {
|
||||
ServerIdentifier ident = new ServerIdentifier(host, payload.port());
|
||||
serverRegistry.acceptInfo(ident, payload);
|
||||
return ResponseEntity.ok(null);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package nl.andrewl.aos2registryapi.dto;
|
||||
|
||||
public record ServerInfoPayload (
|
||||
int port,
|
||||
String name,
|
||||
String description,
|
||||
int maxPlayers,
|
||||
int currentPlayers
|
||||
) {}
|
|
@ -0,0 +1,11 @@
|
|||
package nl.andrewl.aos2registryapi.dto;
|
||||
|
||||
public record ServerInfoResponse (
|
||||
String host,
|
||||
int port,
|
||||
String name,
|
||||
String description,
|
||||
int maxPlayers,
|
||||
int currentPlayers,
|
||||
long lastUpdatedAt
|
||||
) {}
|
|
@ -0,0 +1,3 @@
|
|||
package nl.andrewl.aos2registryapi.model;
|
||||
|
||||
public record ServerIdentifier(String host, int port) {}
|
|
@ -0,0 +1,59 @@
|
|||
package nl.andrewl.aos2registryapi.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class ServerInfo {
|
||||
private String name;
|
||||
private String description;
|
||||
private int maxPlayers;
|
||||
private int currentPlayers;
|
||||
private Instant lastUpdatedAt;
|
||||
|
||||
public ServerInfo(String name, String description, int maxPlayers, int currentPlayers) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.maxPlayers = maxPlayers;
|
||||
this.currentPlayers = currentPlayers;
|
||||
this.lastUpdatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public int getMaxPlayers() {
|
||||
return maxPlayers;
|
||||
}
|
||||
|
||||
public int getCurrentPlayers() {
|
||||
return currentPlayers;
|
||||
}
|
||||
|
||||
public Instant getLastUpdatedAt() {
|
||||
return lastUpdatedAt;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public void setMaxPlayers(int maxPlayers) {
|
||||
this.maxPlayers = maxPlayers;
|
||||
}
|
||||
|
||||
public void setCurrentPlayers(int currentPlayers) {
|
||||
this.currentPlayers = currentPlayers;
|
||||
}
|
||||
|
||||
public void setLastUpdatedAt(Instant lastUpdatedAt) {
|
||||
this.lastUpdatedAt = lastUpdatedAt;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
spring.main.web-application-type=REACTIVE
|
|
@ -0,0 +1,13 @@
|
|||
package nl.andrewl.aos2registryapi;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class Aos2RegistryApiApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<artifactId>ace-of-shades-2</artifactId>
|
||||
<groupId>nl.andrewl</groupId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
@ -33,6 +33,12 @@
|
|||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.0</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.9.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -107,12 +107,16 @@ public class ClientCommunicationHandler {
|
|||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (server.getPlayerManager().getPlayers().size() >= server.getConfig().maxPlayers) {
|
||||
Net.write(new ConnectRejectMessage("Server is full."), 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();
|
||||
connectionEstablished = true;
|
||||
this.player = server.getPlayerManager().register(this, connectMsg.username());
|
||||
this.player = server.getPlayerManager().register(this, connectMsg.username(), connectMsg.spectator());
|
||||
Net.write(new ConnectAcceptMessage(player.getId()), out);
|
||||
sendInitialData();
|
||||
sendTcpMessage(ChatMessage.privateMessage("Welcome to the server, " + player.getUsername() + "."));
|
||||
|
@ -123,6 +127,7 @@ public class ClientCommunicationHandler {
|
|||
TcpReceiver tcpReceiver = new TcpReceiver(in, this::handleTcpMessage)
|
||||
.withShutdownHook(() -> server.getPlayerManager().deregister(this.player));
|
||||
new Thread(tcpReceiver).start();
|
||||
connectionEstablished = true;
|
||||
}
|
||||
} catch (SocketTimeoutException e) {
|
||||
// Ignore this one, since this will happen if the client doesn't send data properly.
|
||||
|
@ -231,7 +236,7 @@ public class ClientCommunicationHandler {
|
|||
out.writeFloat(player.getOrientation().y());
|
||||
|
||||
out.writeBoolean(player.isCrouching());
|
||||
out.writeInt(player.getInventory().getSelectedItemStack().getType().getId());
|
||||
out.writeInt(player.getInventory().getSelectedItemStack() == null ? -1 : player.getInventory().getSelectedItemStack().getType().getId());
|
||||
out.writeByte(player.getInventory().getSelectedBlockValue());
|
||||
out.writeInt(player.getMode().ordinal());
|
||||
}
|
||||
|
|
|
@ -29,22 +29,30 @@ public class PlayerManager {
|
|||
this.server = server;
|
||||
}
|
||||
|
||||
public synchronized ServerPlayer register(ClientCommunicationHandler handler, String username) {
|
||||
ServerPlayer player = new ServerPlayer(nextClientId++, username);
|
||||
public synchronized ServerPlayer register(ClientCommunicationHandler handler, String username, boolean spectator) {
|
||||
PlayerMode mode = spectator ? PlayerMode.SPECTATOR : PlayerMode.NORMAL;
|
||||
Team team = mode != PlayerMode.NORMAL ? null : findBestTeamForNewPlayer();
|
||||
ServerPlayer player = new ServerPlayer(nextClientId++, username, team, mode);
|
||||
if (player.getMode() == PlayerMode.NORMAL || player.getMode() == PlayerMode.CREATIVE) {
|
||||
var inv = player.getInventory();
|
||||
inv.getItemStacks().add(new GunItemStack(ItemTypes.RIFLE));
|
||||
inv.getItemStacks().add(new GunItemStack(ItemTypes.AK_47));
|
||||
inv.getItemStacks().add(new GunItemStack(ItemTypes.WINCHESTER));
|
||||
inv.getItemStacks().add(new BlockItemStack(ItemTypes.BLOCK, 50, (byte) 1));
|
||||
inv.setSelectedIndex(0);
|
||||
}
|
||||
|
||||
System.out.printf("Registered player \"%s\" with id %d.%n", player.getUsername(), player.getId());
|
||||
players.put(player.getId(), player);
|
||||
clientHandlers.put(player.getId(), handler);
|
||||
String joinMessage;
|
||||
Team team = findBestTeamForNewPlayer();
|
||||
if (team != null) {
|
||||
player.setTeam(team);
|
||||
System.out.printf("Player \"%s\" joined the \"%s\" team.%n", player.getUsername(), team.getName());
|
||||
joinMessage = String.format("%s joined the %s team.", username, team.getName());
|
||||
if (player.getTeam() != null) {
|
||||
System.out.printf("Player \"%s\" joined the \"%s\" team.%n", player.getUsername(), player.getTeam().getName());
|
||||
joinMessage = String.format("%s joined the %s team.", username, player.getTeam().getName());
|
||||
} else {
|
||||
joinMessage = username + " joined the game.";
|
||||
}
|
||||
player.setPosition(getBestSpawnPoint(player));
|
||||
setMode(player, PlayerMode.NORMAL);
|
||||
// Tell all other players that this one has joined.
|
||||
broadcastTcpMessageToAllBut(new PlayerJoinMessage(
|
||||
player.getId(), player.getUsername(), player.getTeam() == null ? -1 : player.getTeam().getId(),
|
||||
|
@ -52,11 +60,13 @@ public class PlayerManager {
|
|||
player.getVelocity().x(), player.getVelocity().y(), player.getVelocity().z(),
|
||||
player.getOrientation().x(), player.getOrientation().y(),
|
||||
player.isCrouching(),
|
||||
player.getInventory().getSelectedItemStack().getType().getId(),
|
||||
player.getInventory().getSelectedItemStack() == null ? -1 : player.getInventory().getSelectedItemStack().getType().getId(),
|
||||
player.getInventory().getSelectedBlockValue(),
|
||||
player.getMode()
|
||||
), player);
|
||||
broadcastTcpMessageToAllBut(ChatMessage.announce(joinMessage), player);
|
||||
if (player.getMode() != PlayerMode.SPECTATOR) {
|
||||
broadcastTcpMessageToAllBut(ChatMessage.announce(joinMessage), player);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
|
@ -163,12 +173,9 @@ public class PlayerManager {
|
|||
*/
|
||||
public void playerKilled(ServerPlayer player, ServerPlayer killedBy) {
|
||||
Vector3f deathPosition = new Vector3f(player.getPosition());
|
||||
player.setPosition(getBestSpawnPoint(player));
|
||||
player.setVelocity(new Vector3f(0));
|
||||
player.incrementDeathCount();
|
||||
resupply(player);
|
||||
broadcastUdpMessage(player.getUpdateMessage(System.currentTimeMillis()));
|
||||
broadcastUdpMessage(new SoundMessage("death", 1, deathPosition));
|
||||
respawn(player);
|
||||
String deathMessage;
|
||||
if (killedBy != null) {
|
||||
killedBy.incrementKillCount();
|
||||
|
@ -198,8 +205,16 @@ public class PlayerManager {
|
|||
handler.sendTcpMessage(ChatMessage.privateMessage("You've been resupplied at your team base."));
|
||||
}
|
||||
|
||||
public void respawn(ServerPlayer player) {
|
||||
player.setPosition(getBestSpawnPoint(player));
|
||||
player.setVelocity(new Vector3f(0));
|
||||
broadcastUdpMessage(player.getUpdateMessage(System.currentTimeMillis()));
|
||||
resupply(player);
|
||||
}
|
||||
|
||||
public void setMode(ServerPlayer player, PlayerMode mode) {
|
||||
player.setMode(mode);
|
||||
var handler = getHandler(player);
|
||||
var inv = player.getInventory();
|
||||
inv.clear();
|
||||
if (mode == PlayerMode.NORMAL || mode == PlayerMode.CREATIVE) {
|
||||
|
@ -208,7 +223,31 @@ public class PlayerManager {
|
|||
inv.getItemStacks().add(new GunItemStack(ItemTypes.WINCHESTER));
|
||||
inv.getItemStacks().add(new BlockItemStack(ItemTypes.BLOCK, 50, (byte) 1));
|
||||
inv.setSelectedIndex(0);
|
||||
handler.sendTcpMessage(new ClientInventoryMessage(inv));
|
||||
broadcastUdpMessage(player.getUpdateMessage(System.currentTimeMillis()));
|
||||
}
|
||||
if (mode != PlayerMode.NORMAL) {
|
||||
player.setTeam(null);
|
||||
broadcastTcpMessage(new PlayerTeamUpdateMessage(player.getId(), -1));
|
||||
} else {
|
||||
player.setTeam(findBestTeamForNewPlayer());
|
||||
broadcastTcpMessage(new PlayerTeamUpdateMessage(player.getId(), player.getTeam() == null ? -1 : player.getTeam().getId()));
|
||||
}
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage("Your mode has been updated to " + mode.name() + "."));
|
||||
}
|
||||
|
||||
public void setTeam(ServerPlayer player, Team team) {
|
||||
if (Objects.equals(team, player.getTeam()) || player.getMode() != PlayerMode.NORMAL) return;
|
||||
player.setTeam(team);
|
||||
broadcastUdpMessage(new PlayerTeamUpdateMessage(player.getId(), team == null ? -1 : team.getId()));
|
||||
respawn(player);
|
||||
String chatMessage;
|
||||
if (team != null) {
|
||||
chatMessage = "%s has changed to the %s team.".formatted(player.getUsername(), team.getName());
|
||||
} else {
|
||||
chatMessage = "%s has changed to not be on a team.".formatted(player.getUsername());
|
||||
}
|
||||
broadcastTcpMessage(ChatMessage.announce(chatMessage));
|
||||
}
|
||||
|
||||
public void handleUdpInit(DatagramInit init, DatagramPacket packet) {
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package nl.andrewl.aos2_server;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Component that sends regular updates to any configured server registries.
|
||||
*/
|
||||
public class RegistryUpdater {
|
||||
private final Server server;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public RegistryUpdater(Server server) {
|
||||
this.server = server;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.build();
|
||||
}
|
||||
|
||||
public void sendUpdates() {
|
||||
var cfg = server.getConfig();
|
||||
if (
|
||||
cfg.registries != null &&
|
||||
cfg.registries.length > 0 &&
|
||||
cfg.name != null && !cfg.name.isBlank()
|
||||
) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("port", cfg.port);
|
||||
data.put("name", cfg.name);
|
||||
data.put("description", cfg.description);
|
||||
data.put("maxPlayers", cfg.maxPlayers);
|
||||
data.put("currentPlayers", server.getPlayerManager().getPlayers().size());
|
||||
String json = new Gson().toJson(data);
|
||||
for (String registryUrl : server.getConfig().registries) {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(registryUrl + "/servers"))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.build();
|
||||
try {
|
||||
var resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) {
|
||||
System.err.println("Error response when sending registry update to " + registryUrl + ": " + resp.statusCode() + " " + resp.body());
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
System.err.println("An error occurred while sending registry update to " + registryUrl + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,7 +24,10 @@ import java.net.*;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* The central server, which mainly contains all the different managers and
|
||||
|
@ -83,11 +86,18 @@ public class Server implements Runnable {
|
|||
running = true;
|
||||
new Thread(new UdpReceiver(datagramSocket, this::handleUdpMessage)).start();
|
||||
new Thread(worldUpdater).start();
|
||||
ScheduledExecutorService executorService = null;
|
||||
if (config.registries != null && config.registries.length > 0) {
|
||||
executorService = Executors.newSingleThreadScheduledExecutor();
|
||||
var registryUpdater = new RegistryUpdater(this);
|
||||
executorService.scheduleAtFixedRate(registryUpdater::sendUpdates, 0, 30, TimeUnit.SECONDS);
|
||||
}
|
||||
System.out.printf("Started AoS2 Server on TCP/UDP port %d; now accepting connections.%n", serverSocket.getLocalPort());
|
||||
while (running) {
|
||||
acceptClientConnection();
|
||||
}
|
||||
System.out.println("Shutting down the server.");
|
||||
if (executorService != null) executorService.shutdown();
|
||||
playerManager.deregisterAll();
|
||||
worldUpdater.shutdown();
|
||||
datagramSocket.close(); // Shuts down the UdpReceiver.
|
||||
|
|
|
@ -33,6 +33,9 @@ public class TeamManager {
|
|||
for (var team : teams.values()) {
|
||||
if (team.getName().equals(ident)) return Optional.of(team);
|
||||
}
|
||||
for (var team : teams.values()) {// Try again ignoring case.
|
||||
if (team.getName().equalsIgnoreCase(ident)) return Optional.of(team);
|
||||
}
|
||||
try {
|
||||
int id = Integer.parseInt(ident);
|
||||
for (var team : teams.values()) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import nl.andrewl.aos2_server.Server;
|
|||
import nl.andrewl.aos2_server.cli.ingame.commands.KillCommand;
|
||||
import nl.andrewl.aos2_server.cli.ingame.commands.KillDeathRatioCommand;
|
||||
import nl.andrewl.aos2_server.cli.ingame.commands.PlayerModeCommand;
|
||||
import nl.andrewl.aos2_server.cli.ingame.commands.TeamsCommand;
|
||||
import nl.andrewl.aos2_server.model.ServerPlayer;
|
||||
import nl.andrewl.aos_core.net.client.ChatMessage;
|
||||
|
||||
|
@ -27,6 +28,7 @@ public class PlayerCommandHandler {
|
|||
commands.put("kd", new KillDeathRatioCommand());
|
||||
commands.put("kill", new KillCommand());
|
||||
commands.put("mode", new PlayerModeCommand());
|
||||
commands.put("teams", new TeamsCommand());
|
||||
}
|
||||
|
||||
public void handle(String rawCommand, ServerPlayer player, ClientCommunicationHandler handler) {
|
||||
|
|
|
@ -6,7 +6,6 @@ import nl.andrewl.aos2_server.cli.ingame.PlayerCommand;
|
|||
import nl.andrewl.aos2_server.model.ServerPlayer;
|
||||
import nl.andrewl.aos_core.model.PlayerMode;
|
||||
import nl.andrewl.aos_core.net.client.ChatMessage;
|
||||
import nl.andrewl.aos_core.net.client.ClientInventoryMessage;
|
||||
|
||||
public class PlayerModeCommand implements PlayerCommand {
|
||||
@Override
|
||||
|
@ -19,9 +18,6 @@ public class PlayerModeCommand implements PlayerCommand {
|
|||
try {
|
||||
PlayerMode mode = PlayerMode.valueOf(modeText);
|
||||
server.getPlayerManager().setMode(player, mode);
|
||||
handler.sendTcpMessage(new ClientInventoryMessage(player.getInventory()));
|
||||
server.getPlayerManager().broadcastUdpMessage(player.getUpdateMessage(System.currentTimeMillis()));
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage("Your mode has been updated to " + mode.name() + "."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage("Invalid mode. Should be NORMAL, CREATIVE, or SPECTATOR."));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package nl.andrewl.aos2_server.cli.ingame.commands;
|
||||
|
||||
import nl.andrewl.aos2_server.ClientCommunicationHandler;
|
||||
import nl.andrewl.aos2_server.Server;
|
||||
import nl.andrewl.aos2_server.cli.ingame.PlayerCommand;
|
||||
import nl.andrewl.aos2_server.model.ServerPlayer;
|
||||
import nl.andrewl.aos_core.model.Team;
|
||||
import nl.andrewl.aos_core.net.client.ChatMessage;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TeamsCommand implements PlayerCommand {
|
||||
@Override
|
||||
public void handle(String[] args, ServerPlayer player, ClientCommunicationHandler handler, Server server) {
|
||||
if (args.length == 0) {
|
||||
String teamsString = server.getTeamManager().getTeams().stream()
|
||||
.map(Team::getName).collect(Collectors.joining(", "));
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage(teamsString));
|
||||
} else {
|
||||
String cmd = args[0].trim().toLowerCase();
|
||||
if (cmd.equals("set")) {
|
||||
if (args.length >= 2) {
|
||||
String teamIdent = args[1].trim();
|
||||
server.getTeamManager().findByIdOrName(teamIdent)
|
||||
.ifPresentOrElse(
|
||||
team -> server.getPlayerManager().setTeam(player, team),
|
||||
() -> handler.sendTcpMessage(ChatMessage.privateMessage("Unknown team."))
|
||||
);
|
||||
} else {
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage("Missing required team identifier."));
|
||||
}
|
||||
} else {
|
||||
handler.sendTcpMessage(ChatMessage.privateMessage("Unknown subcommand."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,11 @@ package nl.andrewl.aos2_server.config;
|
|||
|
||||
public class ServerConfig {
|
||||
public int port = 25565;
|
||||
public String name = "My Server";
|
||||
public String description = "My server";
|
||||
public String[] registries = new String[0];
|
||||
|
||||
public int maxPlayers = 32;
|
||||
public int connectionBacklog = 5;
|
||||
public float ticksPerSecond = 20.0f;
|
||||
public String world = "worlds.redfort";
|
||||
|
|
|
@ -2,6 +2,8 @@ package nl.andrewl.aos2_server.model;
|
|||
|
||||
import nl.andrewl.aos2_server.logic.PlayerActionManager;
|
||||
import nl.andrewl.aos_core.model.Player;
|
||||
import nl.andrewl.aos_core.model.PlayerMode;
|
||||
import nl.andrewl.aos_core.model.Team;
|
||||
import nl.andrewl.aos_core.model.item.Inventory;
|
||||
import nl.andrewl.aos_core.net.client.PlayerUpdateMessage;
|
||||
|
||||
|
@ -24,8 +26,8 @@ public class ServerPlayer extends Player {
|
|||
private int deathCount;
|
||||
private int killCount;
|
||||
|
||||
public ServerPlayer(int id, String username) {
|
||||
super(id, username);
|
||||
public ServerPlayer(int id, String username, Team team, PlayerMode mode) {
|
||||
super(id, username, team, mode);
|
||||
this.inventory = new Inventory(new ArrayList<>(), 0);
|
||||
this.health = 1f;
|
||||
this.actionManager = new PlayerActionManager(this);
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
# Ace of Shades 2 Server Configuration
|
||||
port: 25565
|
||||
name: My Server
|
||||
description: This is my Ace of Shades server.
|
||||
registries:
|
||||
- https://reg.aos2.net
|
||||
maxPlayers: 32
|
||||
connectionBacklog: 5
|
||||
ticksPerSecond: 20.0
|
||||
world: worlds.redfort
|
||||
|
|
Loading…
Reference in New Issue