diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b63da45
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..a9d7db9
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# GitHub Copilot persisted chat sessions
+/copilot/chatSessions
diff --git a/.idea/discord.xml b/.idea/discord.xml
new file mode 100644
index 0000000..d8e9561
--- /dev/null
+++ b/.idea/discord.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..2a65317
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..135bac0
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml
new file mode 100644
index 0000000..2b63946
--- /dev/null
+++ b/.idea/uiDesigner.xml
@@ -0,0 +1,124 @@
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Lobby/region/r.-1.-1.mca b/Lobby/region/r.-1.-1.mca
new file mode 100644
index 0000000..0d770a6
Binary files /dev/null and b/Lobby/region/r.-1.-1.mca differ
diff --git a/Lobby/region/r.-1.0.mca b/Lobby/region/r.-1.0.mca
new file mode 100644
index 0000000..0dbbbc5
Binary files /dev/null and b/Lobby/region/r.-1.0.mca differ
diff --git a/Lobby/region/r.0.-1.mca b/Lobby/region/r.0.-1.mca
new file mode 100644
index 0000000..c4f7dbf
Binary files /dev/null and b/Lobby/region/r.0.-1.mca differ
diff --git a/Lobby/region/r.0.0.mca b/Lobby/region/r.0.0.mca
new file mode 100644
index 0000000..0d1a657
Binary files /dev/null and b/Lobby/region/r.0.0.mca differ
diff --git a/arena/Icons.java b/arena/Icons.java
new file mode 100644
index 0000000..d7c4da9
--- /dev/null
+++ b/arena/Icons.java
@@ -0,0 +1,14 @@
+package net.minestom.arena;
+
+public final class Icons {
+ public static final String SWORD = "\uD83D\uDDE1";
+ public static final String BOW = "\uD83C\uDFF9";
+ public static final String SHIELD = "\uD83D\uDEE1";
+ public static final String POTION = "\uD83E\uDDEA";
+ public static final String AXE = "\uD83E\uDE93";
+ public static final String STAR = "\u2606";
+ public static final String CHECKMARK = "\u2714";
+ public static final String CROSS = "\u274C";
+
+ private Icons() {}
+}
diff --git a/arena/Items.java b/arena/Items.java
new file mode 100644
index 0000000..39859f8
--- /dev/null
+++ b/arena/Items.java
@@ -0,0 +1,27 @@
+package net.minestom.arena;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.minestom.arena.utils.ItemUtils;
+import net.minestom.server.item.ItemStack;
+import net.minestom.server.item.Material;
+
+public final class Items {
+ public static final ItemStack CLOSE = ItemUtils.stripItalics(ItemStack.builder(Material.BARRIER)
+ .displayName(Component.text("Close", NamedTextColor.RED))
+ .lore(Component.text("Close this page", NamedTextColor.GRAY))
+ .build());
+ public static final ItemStack CONTINUE = ItemUtils.stripItalics(ItemStack.builder(Material.FEATHER)
+ .displayName(Component.text("Continue", NamedTextColor.GOLD))
+ .lore(Component.text("Continue to the next stage", NamedTextColor.GRAY))
+ .build());
+ public static final ItemStack BACK = ItemUtils.stripItalics(ItemStack.builder(Material.ARROW)
+ .displayName(Component.text("Back", NamedTextColor.AQUA))
+ .lore(Component.text("Go back to the previous page", NamedTextColor.GRAY))
+ .build());
+ public static final ItemStack COIN = ItemUtils.stripItalics(ItemStack.builder(Material.SUNFLOWER)
+ .displayName(Component.text("Coin"))
+ .build());
+
+ private Items() {}
+}
diff --git a/arena/Main.java b/arena/Main.java
new file mode 100644
index 0000000..eaa03dd
--- /dev/null
+++ b/arena/Main.java
@@ -0,0 +1,135 @@
+package net.minestom.arena;
+
+import net.kyori.adventure.sound.Sound;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.minestom.arena.game.ArenaCommand;
+import net.minestom.arena.game.mob.MobTestCommand;
+import net.minestom.arena.group.GroupCommand;
+import net.minestom.arena.group.GroupEvent;
+import net.minestom.arena.lobby.Lobby;
+import net.minestom.arena.utils.ResourceUtils;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.adventure.audience.Audiences;
+import net.minestom.server.command.CommandManager;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.GameMode;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.GlobalEventHandler;
+import net.minestom.server.event.player.PlayerChatEvent;
+import net.minestom.server.event.player.PlayerDisconnectEvent;
+import net.minestom.server.event.player.PlayerLoginEvent;
+import net.minestom.server.event.player.PlayerSpawnEvent;
+import net.minestom.server.event.server.ServerTickMonitorEvent;
+import net.minestom.server.extras.MojangAuth;
+import net.minestom.server.extras.lan.OpenToLAN;
+import net.minestom.server.extras.velocity.VelocityProxy;
+import net.minestom.server.monitoring.TickMonitor;
+import net.minestom.server.sound.SoundEvent;
+import net.minestom.server.timer.TaskSchedule;
+import net.minestom.server.utils.MathUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static net.minestom.arena.config.ConfigHandler.CONFIG;
+
+final class Main {
+ private static final Logger LOGGER = LoggerFactory.getLogger(Main.class);
+
+ public static void main(String[] args) {
+ MinecraftServer minecraftServer = MinecraftServer.init();
+ if (CONFIG.prometheus().enabled()) Metrics.init();
+
+ try {
+ ResourceUtils.extractResource("lobby");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ // Commands
+ {
+ CommandManager manager = MinecraftServer.getCommandManager();
+ manager.setUnknownCommandCallback((sender, c) -> Messenger.warn(sender, "Command not found."));
+ manager.register(new GroupCommand());
+ manager.register(new ArenaCommand());
+ manager.register(new MobTestCommand());
+ SimpleCommands.register(manager);
+ }
+
+ // Events
+ {
+ GlobalEventHandler handler = MinecraftServer.getGlobalEventHandler();
+
+ // Group events
+ GroupEvent.hook(handler);
+ // Server list
+ ServerList.hook(handler);
+
+ // Login
+ handler.addListener(PlayerLoginEvent.class, event -> {
+ final Player player = event.getPlayer();
+ event.setSpawningInstance(Lobby.INSTANCE);
+ player.setRespawnPoint(new Pos(0.5, 16, 0.5));
+
+ if (CONFIG.permissions().operators().contains(player.getUsername())) {
+ player.setPermissionLevel(4);
+ }
+
+ Audiences.all().sendMessage(Component.text(
+ player.getUsername() + " has joined",
+ NamedTextColor.GREEN
+ ));
+ });
+
+ handler.addListener(PlayerSpawnEvent.class, event -> {
+ if (!event.isFirstSpawn()) return;
+ final Player player = event.getPlayer();
+ Messenger.info(player, "Welcome to Minestom Arena, use /arena to play!");
+ player.setGameMode(GameMode.ADVENTURE);
+ player.playSound(Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.MASTER, 1f, 1f));
+ player.setEnableRespawnScreen(false);
+ });
+
+ // Logout
+ handler.addListener(PlayerDisconnectEvent.class, event -> Audiences.all().sendMessage(Component.text(
+ event.getPlayer().getUsername() + " has left",
+ NamedTextColor.RED
+ )));
+
+ // Chat
+ handler.addListener(PlayerChatEvent.class, chatEvent -> {
+ chatEvent.setChatFormat((event) -> Component.text(event.getEntity().getUsername())
+ .append(Component.text(" | ", NamedTextColor.DARK_GRAY)
+ .append(Component.text(event.getMessage(), NamedTextColor.WHITE))));
+ });
+
+ // Monitoring
+ AtomicReference lastTick = new AtomicReference<>();
+ handler.addListener(ServerTickMonitorEvent.class, event -> {
+ final TickMonitor monitor = event.getTickMonitor();
+ Metrics.TICK_TIME.observe(monitor.getTickTime());
+ Metrics.ACQUISITION_TIME.observe(monitor.getAcquisitionTime());
+ lastTick.set(monitor);
+ });
+ MinecraftServer.getExceptionManager().setExceptionHandler(e -> {
+ LOGGER.error("Global exception handler", e);
+ Metrics.EXCEPTIONS.labels(e.getClass().getSimpleName()).inc();
+ });
+
+ // Header/footer
+ }
+
+ if (CONFIG.proxy().enabled()) {
+ VelocityProxy.enable(CONFIG.proxy().secret());
+ } else {
+ OpenToLAN.open();
+ if (CONFIG.server().mojangAuth()) MojangAuth.init();
+ }
+
+ minecraftServer.start(CONFIG.server().address());
+ System.out.println("Server startup done! Using configuration " + CONFIG);
+ }
+}
diff --git a/arena/Messenger.java b/arena/Messenger.java
new file mode 100644
index 0000000..f6875df
--- /dev/null
+++ b/arena/Messenger.java
@@ -0,0 +1,56 @@
+package net.minestom.arena;
+
+import net.kyori.adventure.audience.Audience;
+import net.kyori.adventure.sound.Sound;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextColor;
+import net.kyori.adventure.title.Title;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.sound.SoundEvent;
+import net.minestom.server.timer.TaskSchedule;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class Messenger {
+ public static final TextColor PINK_COLOR = TextColor.color(209, 72, 212);
+ public static final TextColor ORANGE_COLOR = TextColor.color(232, 175, 53);
+
+ public static void info(Audience audience, String message) {
+ info(audience, Component.text(message));
+ }
+
+ public static void info(Audience audience, Component message) {
+ audience.sendMessage(Component.text("! ", PINK_COLOR)
+ .append(message.color(NamedTextColor.GRAY)));
+ }
+
+ public static void warn(Audience audience, String message) {
+ warn(audience, Component.text(message));
+ }
+
+ public static void warn(Audience audience, Component message) {
+ audience.sendMessage(Component.text("* ", ORANGE_COLOR)
+ .append(message.color(NamedTextColor.GRAY)));
+ }
+
+ public static CompletableFuture countdown(Audience audience, int from) {
+ final CompletableFuture completableFuture = new CompletableFuture<>();
+ final AtomicInteger countdown = new AtomicInteger(from);
+ MinecraftServer.getSchedulerManager().submitTask(() -> {
+ final int count = countdown.getAndDecrement();
+ if (count <= 0) {
+ completableFuture.complete(null);
+ return TaskSchedule.stop();
+ }
+
+ audience.showTitle(Title.title(Component.text(count, NamedTextColor.GREEN), Component.empty()));
+ audience.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_PLING, Sound.Source.BLOCK, 1, 1), Sound.Emitter.self());
+
+ return TaskSchedule.seconds(1);
+ });
+
+ return completableFuture;
+ }
+}
diff --git a/arena/Metrics.java b/arena/Metrics.java
new file mode 100644
index 0000000..2aaa7e8
--- /dev/null
+++ b/arena/Metrics.java
@@ -0,0 +1,156 @@
+package net.minestom.arena;
+
+import com.sun.management.OperatingSystemMXBean;
+import io.prometheus.client.*;
+import io.prometheus.client.exporter.HTTPServer;
+import io.prometheus.client.hotspot.GarbageCollectorExports;
+import io.prometheus.client.hotspot.MemoryPoolsExports;
+import net.minestom.arena.config.ConfigHandler;
+import net.minestom.arena.utils.NetworkUsage;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.entity.EntitySpawnEvent;
+import net.minestom.server.event.instance.RemoveEntityFromInstanceEvent;
+import net.minestom.server.event.player.PlayerDisconnectEvent;
+import net.minestom.server.event.player.PlayerLoginEvent;
+import net.minestom.server.event.player.PlayerPacketEvent;
+import net.minestom.server.event.player.PlayerPacketOutEvent;
+
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class Metrics {
+ public static final Gauge ENTITIES = Gauge.build().name("entities")
+ .help("Total entities alive (excluding players)").register();
+ public static final Gauge GAMES_IN_PROGRESS = Gauge.build().name("games_in_progress")
+ .labelNames("type").help("Games currently running").register();
+ public static final Counter GAMES_PLAYED = Counter.build().name("games_played")
+ .labelNames("type").help("Number of games played").register();
+ public static final Summary TICK_TIME = Summary.build().name("tick_time")
+ .help("ms per tick").quantile(0, 1).quantile(.5, .01).quantile(1, 0)
+ .maxAgeSeconds(5).unit("ms").register();
+ public static final Summary ACQUISITION_TIME = Summary.build().name("acquisition_time")
+ .help("ms per acquisition").quantile(0, 1).quantile(.5, .01).quantile(1, 0)
+ .maxAgeSeconds(5).unit("ms").register();
+ public static final Counter EXCEPTIONS = Counter.build().name("exceptions")
+ .help("Number of exceptions").labelNames("simple_name").register();
+ private static final Counter PACKETS = Counter.build().name("packets").help("Number of packets by direction")
+ .labelNames("direction").register();
+ private static final Gauge ONLINE_PLAYERS = Gauge.build().name("online_players")
+ .help("Number of currently online players").register();
+ private static final Info GENERIC_INFO = Info.build().name("generic").help("Generic system information")
+ .register();
+ private static final OperatingSystemMXBean systemMXBean = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class);
+
+
+ public static void init() {
+ try {
+ final String unknown = "unknown";
+ GENERIC_INFO.info(
+ "java_version", System.getProperty("java.version", unknown),
+ "java_vendor", System.getProperty("java.vendor", unknown),
+ "os_arch", System.getProperty("os.arch", unknown),
+ "os_name", System.getProperty("os.name", unknown),
+ "os_version", System.getProperty("os.version", unknown),
+ "available_processors", ""+systemMXBean.getAvailableProcessors()
+ );
+
+ // Packets & players
+ MinecraftServer.getGlobalEventHandler()
+ .addListener(PlayerPacketEvent.class, e -> Metrics.PACKETS.labels("in").inc())
+ .addListener(PlayerPacketOutEvent.class, e -> Metrics.PACKETS.labels("out").inc())
+ .addListener(PlayerLoginEvent.class, e -> Metrics.ONLINE_PLAYERS.inc())
+ .addListener(PlayerDisconnectEvent.class, e -> Metrics.ONLINE_PLAYERS.dec())
+ .addListener(EntitySpawnEvent.class, e -> {
+ if (!(e.getEntity() instanceof Player)) Metrics.ENTITIES.inc();
+ }).addListener(RemoveEntityFromInstanceEvent.class, e -> {
+ if (!(e.getEntity() instanceof Player)) Metrics.ENTITIES.dec();
+ });
+
+ // Network usage
+ if (NetworkUsage.checkEnabledOrExtract()) {
+ NetworkUsage.resetCounters();
+ NetworkCounter.build().name("network_io").help("Network usage").unit("bytes").labelNames("direction")
+ .register();
+ }
+
+ CPUGauge.build().name("cpu").help("CPU Usage").register();
+ new HTTPServer(ConfigHandler.CONFIG.prometheus().port());
+ new MemoryPoolsExports().register();
+ new GarbageCollectorExports().register();
+ } catch (IOException e) {
+ MinecraftServer.getExceptionManager().handleException(e);
+ }
+ }
+
+ private static class NetworkCounter extends SimpleCollector {
+ private final double created = System.currentTimeMillis()/1000f;
+ private final static List outLabels = List.of("out");
+ private final static List inLabels = List.of("in");
+
+ protected NetworkCounter(Builder b) {
+ super(b);
+ }
+
+ public static class Builder extends SimpleCollector.Builder {
+
+ @Override
+ public NetworkCounter create() {
+ return new NetworkCounter(this);
+ }
+ }
+
+ public static Builder build() {
+ return new Builder();
+ }
+
+ @Override
+ protected Counter.Child newChild() {
+ return null;
+ }
+
+ @Override
+ public List collect() {
+ List samples = new ArrayList();
+ samples.add(new MetricFamilySamples.Sample(fullname + "_total", labelNames, outLabels, NetworkUsage.getBytesSent()));
+ samples.add(new MetricFamilySamples.Sample(fullname + "_created", labelNames, outLabels, created));
+ samples.add(new MetricFamilySamples.Sample(fullname + "_total", labelNames, inLabels, NetworkUsage.getBytesReceived()));
+ samples.add(new MetricFamilySamples.Sample(fullname + "_created", labelNames, inLabels, created));
+ return familySamplesList(Type.COUNTER, samples);
+ }
+ }
+
+ private static class CPUGauge extends SimpleCollector {
+
+ protected CPUGauge(Builder b) {
+ super(b);
+ }
+
+ public static class Builder extends SimpleCollector.Builder {
+
+ @Override
+ public CPUGauge create() {
+ return new CPUGauge(this);
+ }
+ }
+
+ public static Builder build() {
+ return new Builder();
+ }
+
+ @Override
+ protected Gauge.Child newChild() {
+ return new Gauge.Child();
+ }
+
+ @Override
+ public List collect() {
+ List samples = new ArrayList(1);
+ samples.add(new MetricFamilySamples.Sample(fullname, labelNames, Collections.emptyList(), systemMXBean.getProcessCpuLoad()));
+ return familySamplesList(Type.GAUGE, samples);
+ }
+ }
+}
diff --git a/arena/ServerList.java b/arena/ServerList.java
new file mode 100644
index 0000000..dfb47fb
--- /dev/null
+++ b/arena/ServerList.java
@@ -0,0 +1,51 @@
+package net.minestom.arena;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.minimessage.MiniMessage;
+import net.minestom.arena.config.ConfigHandler;
+import net.minestom.arena.config.ConfigurationReloadedEvent;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.server.ServerListPingEvent;
+import net.minestom.server.ping.ResponseData;
+
+import java.io.InputStream;
+import java.util.Base64;
+import java.util.List;
+
+final class ServerList {
+ private static final String FAVICON = favicon();
+ private static Component motd = motd();
+
+ public static void hook(EventNode eventNode) {
+ eventNode.addListener(ServerListPingEvent.class, event -> {
+ final ResponseData responseData = event.getResponseData();
+ responseData.setDescription(motd);
+ if (FAVICON != null)
+ responseData.setFavicon(FAVICON);
+ responseData.setMaxPlayer(100);
+ responseData.addEntries(MinecraftServer.getConnectionManager().getOnlinePlayers());
+ }).addListener(ConfigurationReloadedEvent.class, e -> motd = motd());
+ }
+
+ private static String favicon() {
+ String favicon = null;
+ try (InputStream stream = Main.class.getResourceAsStream("/favicon.png")) {
+ if (stream != null)
+ favicon = "data:image/png;base64," + Base64.getEncoder().encodeToString(stream.readAllBytes());
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ return favicon;
+ }
+
+ private static Component motd() {
+ final MiniMessage miniMessage = MiniMessage.miniMessage();
+ final List motd = ConfigHandler.CONFIG.server().motd();
+ return motd.stream()
+ .map(miniMessage::deserialize)
+ .reduce(Component.empty(), (a, b) -> a.append(b).appendNewline());
+ }
+}
diff --git a/arena/SimpleCommands.java b/arena/SimpleCommands.java
new file mode 100644
index 0000000..e2fd4c7
--- /dev/null
+++ b/arena/SimpleCommands.java
@@ -0,0 +1,53 @@
+package net.minestom.arena;
+
+import net.minestom.arena.config.ConfigHandler;
+import net.minestom.arena.game.ArenaManager;
+import net.minestom.arena.group.GroupManager;
+import net.minestom.arena.lobby.Lobby;
+import net.minestom.arena.utils.CommandUtils;
+import net.minestom.server.command.CommandManager;
+import net.minestom.server.command.ConsoleSender;
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.entity.Player;
+
+import java.util.List;
+
+/**
+ * Place for commands with little to no complexity
+ */
+final class SimpleCommands {
+ private SimpleCommands() {}
+
+ public static void register(CommandManager manager) {
+ for (Command command : commands())
+ manager.register(command);
+ }
+
+ private static List commands() {
+ final Command stop = new Command("stop");
+ stop.setCondition((sender, commandString) -> sender instanceof ConsoleSender ||
+ (sender instanceof Player player && player.getPermissionLevel() == 4));
+ stop.setDefaultExecutor((sender, context) -> ArenaManager.stopServer());
+
+ final Command ping = new Command("ping", "latency");
+ ping.setDefaultExecutor((sender, context) -> {
+ final Player player = (Player) sender;
+ Messenger.info(player, "Your ping is " + player.getLatency() + "ms");
+ });
+
+ final Command leave = new Command("leave", "l");
+ leave.setCondition(CommandUtils::arenaOnly);
+ leave.setDefaultExecutor((sender, context) -> {
+ final Player player = (Player) sender;
+ player.setInstance(Lobby.INSTANCE);
+ player.setHealth(player.getMaxHealth());
+ GroupManager.removePlayer(player);
+ });
+
+ final Command reload = new Command("reload");
+ reload.setCondition(CommandUtils::consoleOnly);
+ reload.setDefaultExecutor(((sender, context) -> ConfigHandler.loadConfig()));
+
+ return List.of(stop, ping, leave, reload);
+ }
+}
diff --git a/arena/config/Config.java b/arena/config/Config.java
new file mode 100644
index 0000000..77a2ea9
--- /dev/null
+++ b/arena/config/Config.java
@@ -0,0 +1,24 @@
+package net.minestom.arena.config;
+
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.List;
+
+public record Config(Server server, Proxy proxy, Permissions permissions, Prometheus prometheus) {
+ public record Server(@Default("0.0.0.0") String host, @Default("25565") int port, @Default("true") boolean mojangAuth, @Default("[\"Line1\",\"Line2\"]") List motd) {
+ public SocketAddress address() {
+ return new InetSocketAddress(host, port);
+ }
+ }
+
+ public record Proxy(@Default("false") boolean enabled, @Default("forwarding-secret") String secret) {
+ @Override
+ public String toString() {
+ return "Proxy[enabled="+enabled+", secret=]";
+ }
+ }
+
+ public record Permissions(@Default("[]") List operators) {}
+
+ public record Prometheus(@Default("false") boolean enabled, @Default("9090") int port) {}
+}
diff --git a/arena/config/ConfigHandler.java b/arena/config/ConfigHandler.java
new file mode 100644
index 0000000..28cb552
--- /dev/null
+++ b/arena/config/ConfigHandler.java
@@ -0,0 +1,174 @@
+package net.minestom.arena.config;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import net.minestom.server.MinecraftServer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Parameter;
+import java.lang.reflect.RecordComponent;
+import java.util.*;
+
+public final class ConfigHandler {
+ public volatile static Config CONFIG;
+ private static boolean reload = false;
+ private static final Logger LOGGER = LoggerFactory.getLogger(ConfigHandler.class);
+ private static final Gson gson = new GsonBuilder()
+ .setPrettyPrinting()
+ .registerTypeAdapterFactory(new RecordTypeAdapterFactory())
+ .create();
+ private static final File configFile = new File("config.json");
+
+ static {
+ loadConfig();
+ }
+
+ public synchronized static void loadConfig() {
+ Config old = CONFIG;
+
+ if (configFile.exists()) {
+ try (JsonReader reader = new JsonReader(new FileReader(configFile))) {
+ CONFIG = gson.fromJson(reader, Config.class);
+ } catch (IOException exception) {
+ LOGGER.error("Failed to load configuration file, using defaults.", exception);
+ loadDefaults();
+ }
+ } else {
+ loadDefaults();
+ try {
+ final FileWriter writer = new FileWriter(configFile);
+ gson.toJson(CONFIG, writer);
+ writer.flush();
+ writer.close();
+ } catch (IOException exception) {
+ LOGGER.error("Failed to write default configuration.", exception);
+ }
+ }
+
+ if (reload) {
+ MinecraftServer.getGlobalEventHandler().call(new ConfigurationReloadedEvent(old, CONFIG));
+ LOGGER.info("Configuration reloaded!");
+ } else {
+ reload = true;
+ }
+ }
+
+ private synchronized static void loadDefaults() {
+ CONFIG = gson.fromJson("{}", Config.class);
+ }
+
+ private ConfigHandler() {}
+
+ private static class RecordTypeAdapterFactory implements TypeAdapterFactory {
+ @Override
+ public TypeAdapter create(Gson gson, TypeToken type) {
+ final Class super T> clazz = type.getRawType();
+ final TypeAdapter delegate = gson.getDelegateAdapter(this, type);
+
+ if (!clazz.isRecord())
+ return null;
+
+ return new TypeAdapter<>() {
+ @Override
+ public void write(JsonWriter out, T value) throws IOException {
+ delegate.write(out, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public T read(JsonReader reader) throws IOException {
+ if (reader.peek() == JsonToken.NULL) {
+ reader.nextNull();
+ return null;
+ } else {
+ final RecordComponent[] recordComponents = clazz.getRecordComponents();
+ final Map> typeMap = new HashMap<>();
+ final Map argsMap = new HashMap<>();
+
+ for (RecordComponent component : recordComponents)
+ typeMap.put(component.getName(), TypeToken.get(component.getGenericType()));
+
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ argsMap.put(name, gson.getAdapter(typeMap.get(name)).read(reader));
+ }
+ reader.endObject();
+
+ Arrays.stream(recordComponents).filter(x -> !argsMap.containsKey(x.getName())).forEach(x -> {
+ final String name = x.getName();
+ final Class> argClazz = x.getType();
+ final Default def = x.getAnnotation(Default.class);
+ if (def == null) {
+ argsMap.put(name, instantiateWithDefaults(argClazz));
+ return;
+ }
+ try {
+ if (argClazz == String.class) {
+ argsMap.put(name, def.value());
+ } else {
+ argsMap.put(name, gson.getAdapter(typeMap.get(name)).fromJson(def.value()));
+ }
+ } catch (IOException ignored) {}
+ });
+
+ final List