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 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 args = new ArrayList<>(); + final List> argTypes = new ArrayList<>(); + for (RecordComponent component : recordComponents) { + args.add(argsMap.get(component.getName())); + argTypes.add(component.getType()); + } + + try { + Constructor constructor = clazz.getDeclaredConstructor(argTypes.toArray(Class[]::new)); + constructor.setAccessible(true); + return (T) constructor.newInstance(args.toArray(Object[]::new)); + } catch (ReflectiveOperationException e) { + return null; + } + } + } + + private Object instantiateWithDefaults(Class clazz) { + final List args = new ArrayList<>(); + final Constructor constructor = clazz.getDeclaredConstructors()[0]; + for (Parameter param : constructor.getParameters()) { + final Class paramClazz = param.getType(); + final Default def = param.getAnnotation(Default.class); + if (def == null) { + args.add(instantiateWithDefaults(paramClazz)); + continue; + } + try { + if (paramClazz == String.class) { + args.add(def.value()); + } else { + args.add(gson.getAdapter(TypeToken.get(param.getType())).fromJson(def.value())); + } + } catch (IOException ignored) { + args.add(null); + } + } + try { + return constructor.newInstance(args.toArray(Object[]::new)); + } catch (InstantiationException | InvocationTargetException | IllegalAccessException e) { + return null; + } + } + }; + } + } +} diff --git a/arena/config/ConfigurationReloadedEvent.java b/arena/config/ConfigurationReloadedEvent.java new file mode 100644 index 0000000..ec93094 --- /dev/null +++ b/arena/config/ConfigurationReloadedEvent.java @@ -0,0 +1,6 @@ +package net.minestom.arena.config; + +import net.minestom.server.event.Event; + +public record ConfigurationReloadedEvent(Config previousConfig, Config currentConfig) implements Event { +} diff --git a/arena/config/Default.java b/arena/config/Default.java new file mode 100644 index 0000000..6bf049c --- /dev/null +++ b/arena/config/Default.java @@ -0,0 +1,9 @@ +package net.minestom.arena.config; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Default { + String value(); +} diff --git a/arena/feature/BowFeature.java b/arena/feature/BowFeature.java new file mode 100644 index 0000000..3db4a9f --- /dev/null +++ b/arena/feature/BowFeature.java @@ -0,0 +1,55 @@ +package net.minestom.arena.feature; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventListener; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.item.ItemUpdateStateEvent; +import net.minestom.server.event.player.PlayerItemAnimationEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.item.Material; +import net.minestom.server.tag.Tag; +import net.minestom.server.utils.MathUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * @param projectileGenerator Uses the return value as the entity to shoot (in lambda arg 1 is shooter, arg 2 is power) + */ +record BowFeature(@NotNull BiFunction projectileGenerator) implements Feature { + private static final Tag CHARGE_SINCE_TAG = Tag.Long("bow_charge_since").defaultValue(Long.MAX_VALUE); + + @Override + public void hook(@NotNull EventNode node) { + node.addListener(EventListener.builder(PlayerItemAnimationEvent.class) + .handler(event -> event.getPlayer().setTag(CHARGE_SINCE_TAG, System.currentTimeMillis())) + .filter(event -> event.getItemAnimationType() == PlayerItemAnimationEvent.ItemAnimationType.BOW) + .build() + ).addListener(EventListener.builder(ItemUpdateStateEvent.class) + .handler(event -> { + final Player player = event.getPlayer(); + final double chargedFor = (System.currentTimeMillis() - player.getTag(CHARGE_SINCE_TAG)) / 1000D; + final double power = MathUtils.clamp((chargedFor * chargedFor + 2 * chargedFor) / 2D, 0, 1); + + if (power > 0.2) { + final EntityProjectile projectile = projectileGenerator.apply(player, power); + final Pos position = player.getPosition().add(0, player.getEyeHeight(), 0); + + projectile.setInstance(Objects.requireNonNull(player.getInstance()), position); + + Vec direction = projectile.getPosition().direction(); + projectile.shoot(position.add(direction).sub(0, 0.2, 0), power * 3, 1.0); + } + + // Restore arrow + player.getInventory().update(); + }) + .filter(event -> event.getItemStack().material() == Material.BOW) + .build()); + } +} diff --git a/arena/feature/CombatFeature.java b/arena/feature/CombatFeature.java new file mode 100644 index 0000000..4871bb1 --- /dev/null +++ b/arena/feature/CombatFeature.java @@ -0,0 +1,122 @@ +package net.minestom.arena.feature; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.entity.hologram.Hologram; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.entity.projectile.ProjectileCollideWithEntityEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.tag.Tag; +import net.minestom.server.utils.MathUtils; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.ToDoubleBiFunction; +import java.util.function.ToLongFunction; + +/** + * @param playerCombat Allow player combat + * @param damageFunction Uses the return value as damage to apply (in lambda arg 1 is attacker, arg 2 is victim) + * @param invulnerabilityFunction Uses the return value as time an entity is invulnerable after getting attacked (in lambda arg 1 is victim) + */ +record CombatFeature(boolean playerCombat, ToDoubleBiFunction damageFunction, ToLongFunction invulnerabilityFunction) implements Feature { + private static final Tag INVULNERABLE_UNTIL_TAG = Tag.Long("invulnerable_until").defaultValue(0L); + + @Override + public void hook(@NotNull EventNode node) { + node.addListener(ProjectileCollideWithEntityEvent.class, event -> { + if (!(event.getTarget() instanceof LivingEntity target)) return; + if (!(event.getEntity() instanceof EntityProjectile projectile)) return; + + // PVP is disabled and two players have attempted to hit each other + if (!playerCombat && target instanceof Player && projectile.getShooter() instanceof Player) return; + + // Don't apply damage if entity is invulnerable + final long now = System.currentTimeMillis(); + final long invulnerableUntil = target.getTag(INVULNERABLE_UNTIL_TAG); + if (invulnerableUntil > now) return; + + float damage = (float) damageFunction.applyAsDouble(projectile, target); + + target.damage(DamageType.fromProjectile(projectile.getShooter(), projectile), damage); + target.setTag(INVULNERABLE_UNTIL_TAG, now + invulnerabilityFunction.applyAsLong(target)); + + takeKnockbackFromArrow(target, projectile); + if (damage > 0) spawnHologram(target, damage); + + projectile.remove(); + }).addListener(EntityAttackEvent.class, event -> { + if (!(event.getTarget() instanceof LivingEntity target)) return; + + // PVP is disabled and two players have attempted to hit each other + if (!playerCombat && target instanceof Player && event.getEntity() instanceof Player) return; + + // Can't have dead sources attacking things + if (((LivingEntity) event.getEntity()).isDead()) return; + + // Don't apply damage if entity is invulnerable + final long now = System.currentTimeMillis(); + final long invulnerableUntil = target.getTag(INVULNERABLE_UNTIL_TAG); + if (invulnerableUntil > now) return; + + float damage = (float) damageFunction.applyAsDouble(event.getEntity(), target); + + target.damage(DamageType.fromEntity(event.getEntity()), damage); + target.setTag(INVULNERABLE_UNTIL_TAG, now + invulnerabilityFunction.applyAsLong(target)); + + takeKnockback(target, event.getEntity()); + if (damage > 0) spawnHologram(target, damage); + }); + } + + private static void takeKnockback(Entity target, Entity source) { + target.takeKnockback( + 0.3f, + Math.sin(source.getPosition().yaw() * (Math.PI / 180)), + -Math.cos(source.getPosition().yaw() * (Math.PI / 180)) + ); + } + + private static void takeKnockbackFromArrow(Entity target, EntityProjectile source) { + if (source.getShooter() == null) return; + takeKnockback(target, source.getShooter()); + } + + private static void spawnHologram(Entity target, float damage) { + damage = MathUtils.round(damage, 2); + + new DamageHologram( + target.getInstance(), + target.getPosition().add(0, target.getEyeHeight(), 0), + Component.text(damage, NamedTextColor.RED) + ); + } + + private static final class DamageHologram extends Hologram { + private DamageHologram(Instance instance, Pos spawnPosition, Component text) { + super(instance, spawnPosition, text, true, true); + getEntity().getEntityMeta().setHasNoGravity(false); + + Random random = ThreadLocalRandom.current(); + getEntity().setVelocity(getPosition() + .direction() + .withX(random.nextDouble(2)) + .withY(3) + .withZ(random.nextDouble(2)) + .normalize().mul(3)); + + getEntity().scheduleRemove(Duration.of(15, TimeUnit.SERVER_TICK)); + } + } +} diff --git a/arena/feature/DropFeature.java b/arena/feature/DropFeature.java new file mode 100644 index 0000000..60124e9 --- /dev/null +++ b/arena/feature/DropFeature.java @@ -0,0 +1,30 @@ +package net.minestom.arena.feature; + +import net.minestom.server.entity.ItemEntity; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.item.ItemStack; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Predicate; + +record DropFeature(Predicate allowPredicate) implements Feature { + @Override + public void hook(@NotNull EventNode node) { + node.addListener(ItemDropEvent.class, event -> { + ItemStack item = event.getItemStack(); + + if (!allowPredicate.test(item)) { + event.setCancelled(true); + return; + } + + ItemEntity itemEntity = new ItemEntity(item); + itemEntity.setPickupDelay(40, TimeUnit.SERVER_TICK); + itemEntity.setInstance(event.getInstance(), event.getPlayer().getPosition().add(0, 1.5, 0)); + itemEntity.setVelocity(event.getPlayer().getPosition().direction().mul(6)); + }); + } +} diff --git a/arena/feature/Feature.java b/arena/feature/Feature.java new file mode 100644 index 0000000..16968ec --- /dev/null +++ b/arena/feature/Feature.java @@ -0,0 +1,10 @@ +package net.minestom.arena.feature; + +import net.minestom.server.event.EventNode; +import net.minestom.server.event.trait.InstanceEvent; +import org.jetbrains.annotations.NotNull; + +@FunctionalInterface +public interface Feature { + void hook(@NotNull EventNode node); +} diff --git a/arena/feature/Features.java b/arena/feature/Features.java new file mode 100644 index 0000000..87d5496 --- /dev/null +++ b/arena/feature/Features.java @@ -0,0 +1,27 @@ +package net.minestom.arena.feature; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.function.*; + +public final class Features { + public static @NotNull Feature bow(BiFunction projectileGenerator) { + return new BowFeature(projectileGenerator); + } + + public static @NotNull Feature combat(boolean combat, ToDoubleBiFunction damageFunction, ToLongFunction invulnerabilityFunction) { + return new CombatFeature(combat, damageFunction, invulnerabilityFunction); + } + + public static @NotNull Feature drop(Predicate allowPredicate) { + return new DropFeature(allowPredicate); + } + + public static @NotNull Feature functionalItem(Predicate trigger, Consumer consumer, long cooldown) { + return new FunctionalItemFeature(trigger, consumer, cooldown); + } +} diff --git a/arena/feature/FunctionalItemFeature.java b/arena/feature/FunctionalItemFeature.java new file mode 100644 index 0000000..014a304 --- /dev/null +++ b/arena/feature/FunctionalItemFeature.java @@ -0,0 +1,38 @@ +package net.minestom.arena.feature; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventListener; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerHandAnimationEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.item.ItemStack; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; + +record FunctionalItemFeature(Predicate trigger, Consumer consumer, long cooldown) implements Feature { + @Override + public void hook(@NotNull EventNode node) { + final UUID random = UUID.randomUUID(); + final Tag lastUseTag = Tag.Long("item_" + random + "_last_use").defaultValue(0L); + + node.addListener(EventListener.builder(PlayerHandAnimationEvent.class) + .handler(event -> { + final Player player = event.getPlayer(); + final long lastUse = player.getTag(lastUseTag); + final long now = System.currentTimeMillis(); + + if (now - lastUse >= cooldown) { + player.setTag(lastUseTag, now); + consumer.accept(player); + } + }) + .filter(event -> trigger.test(event.getPlayer().getItemInHand(event.getHand()))) + .filter(event -> event.getPlayer().getOpenInventory() == null) + .build() + ); + } +} diff --git a/arena/game/AbstractArena.java b/arena/game/AbstractArena.java new file mode 100644 index 0000000..9d02ba1 --- /dev/null +++ b/arena/game/AbstractArena.java @@ -0,0 +1,209 @@ +package net.minestom.arena.game; + +import net.minestom.arena.group.Group; +import net.minestom.arena.utils.ConcurrentUtils; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +public abstract class AbstractArena implements Arena { + private final CompletableFuture gameFuture = new CompletableFuture<>(); + private final AtomicReference state = new AtomicReference<>(GameState.CREATED); + private Instant startDate; + private Instant endDate; + private final static Duration END_TIMEOUT = Duration.ofMinutes(10); + + /////////////////////////////////////////////////////////////////////////// + // Getter methods + /////////////////////////////////////////////////////////////////////////// + + /** + * Used to get a future that represents this game life + * + * @return a future that is completed when the game state is either {@link GameState#ENDED} or {@link GameState#KILLED} + */ + public final CompletableFuture gameFuture() { + return this.gameFuture; + } + + public final Instant startDate() { + return startDate; + } + + public final Instant stopDate() { + return endDate; + } + + public final GameState state() { + return this.state.get(); + } + + public abstract @NotNull Group group(); + + /////////////////////////////////////////////////////////////////////////// + // Life cycle methods + /////////////////////////////////////////////////////////////////////////// + + /** + * Used to prepare the game for players e.g. generate the world, summon entities, register listeners, etc. + * Players SHOULD NOT be altered in this state + * + * @return a future that completes when the game can be started with {@link #start()} + */ + public abstract @NotNull CompletableFuture init(); + + /** + * Used to start the game, here you can change the players' instance, etc. + * + * @return a future that completes when the actual gameplay begins + */ + protected abstract CompletableFuture onStart(); + + /** + * Used to start the game, the start sequence is the following (note that a shutdown will interrupt this flow): + *
    + *
  1. Set state to {@link GameState#INITIALIZING}
  2. + *
  3. Execute {@link #init()}
  4. + *
  5. Set state to {@link GameState#STARTING}
  6. + *
  7. Execute {@link #onStart()}
  8. + *
  9. Set state to {@link GameState#STARTED}
  10. + *
+ * + * @throws RuntimeException if called when the state isn't {@link GameState#CREATED} + */ + public final void start() { + if (!this.state.compareAndSet(GameState.CREATED, GameState.INITIALIZING)) { + throw new RuntimeException("Cannot start a AbstractArena twice!"); + } + init().thenRun(() -> { + if (!this.state.compareAndSet(GameState.INITIALIZING, GameState.STARTING)) { + // A shutdown has been initiated during initialization, don't start the game + return; + } + onStart().thenRun(() -> { + if (!this.state.compareAndSet(GameState.STARTING, GameState.STARTED)) { + // A shutdown has been initiated during game start, don't change state + return; + } + this.startDate = Instant.now(); + }); + }); + } + + /** + * Used to reset the players after the game + * + * @return a future that completes when all players have been reset, this SHOULD NOT wait on gameplay + */ + protected abstract CompletableFuture onEnd(); + + /** + * Used to end the game normally, only the first call will execute {@link #onEnd()} + * multiple calls to this method will be ignored + * + * @return {@link #gameFuture()} + */ + public final CompletableFuture end() { + if (!tryAdvance(GameState.ENDING)) { + return gameFuture(); + } + onEnd().thenRun(() -> { + if (!tryAdvance(GameState.ENDED)) { + // AbstractArena was killed, don't alter the state + return; + } + this.endDate = Instant.now(); + this.gameFuture.complete(null); + }); + return gameFuture(); + } + + /** + * Used to prepare the game for ending within the specified timeout + * + * @param shutdownTimeout duration in which the game should end + * @return a future which is completed when the internal state of the game allows the call of {@link #end()} + */ + protected abstract CompletableFuture onShutdown(Duration shutdownTimeout); + + /** + * Used to shut down the game gracefully, shutdown process id the following: + *
    + *
  1. Call {@link #onShutdown(Duration)} with the timeout
  2. + *
  3. Wait for the returned future to complete or the timeout to be reached
  4. + *
  5. If (A) the timeout wasn't reached continue with the normal ending procedure by calling {@link #end()} + * or if it was reached, but (B) the game already ended then return otherwise (C) kill the game
  6. + *
+ * + * @return {@link #gameFuture()} + */ + public final CompletableFuture shutdown() { + if (!tryAdvance(GameState.SHUTTING_DOWN)) { + return gameFuture(); + } + + ConcurrentUtils.thenRunOrTimeout(onShutdown(END_TIMEOUT), END_TIMEOUT, (timeoutReached) -> { + if (timeoutReached) { + if (!tryAdvance(GameState.KILLED)) { + // The game ended already, we can safely return + return; + } + // Kill game + this.endDate = Instant.now(); + this.gameFuture.complete(null); + this.kill(); + } else { + // Execute normal end procedure + end(); + } + }); + return gameFuture(); + } + + /** + * Called when the game didn't finish in time after {@link #shutdown()} has been called + */ + protected void kill() {} + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + + /** + * Advance game state + * + * @return true if the game state advanced, false otherwise + */ + private boolean tryAdvance(GameState newState) { + return ConcurrentUtils.testAndSet(this.state, GameState::isBefore, newState); + } + + public enum GameState { + CREATED(0), INITIALIZING(1), STARTING(2), STARTED(3), ENDING(4), SHUTTING_DOWN(4), ENDED(5), KILLED(5); + + private final int sequence; + + GameState(int sequence) { + this.sequence = sequence; + } + + public boolean isBefore(GameState state) { + return this.sequence < state.sequence; + } + + public boolean isOrBefore(GameState state) { + return this.sequence <= state.sequence; + } + + public boolean isAfter(GameState state) { + return this.sequence > state.sequence; + } + + public boolean isOrAfter(GameState state) { + return this.sequence >= state.sequence; + } + } +} diff --git a/arena/game/Arena.java b/arena/game/Arena.java new file mode 100644 index 0000000..55d48c8 --- /dev/null +++ b/arena/game/Arena.java @@ -0,0 +1,21 @@ +package net.minestom.arena.game; + +import net.minestom.arena.group.Group; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +public interface Arena { + @NotNull Group group(); + + @NotNull CompletableFuture init(); + + void start(); + void stop(); + + @ApiStatus.NonExtendable + default void unregister() { + ArenaManager.unregister(this); + } +} diff --git a/arena/game/ArenaCommand.java b/arena/game/ArenaCommand.java new file mode 100644 index 0000000..7f0f44f --- /dev/null +++ b/arena/game/ArenaCommand.java @@ -0,0 +1,161 @@ +package net.minestom.arena.game; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Items; +import net.minestom.arena.lobby.Lobby; +import net.minestom.arena.Messenger; +import net.minestom.arena.group.Group; +import net.minestom.arena.utils.CommandUtils; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentEnum; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.Inventory; +import net.minestom.server.inventory.InventoryType; +import net.minestom.server.inventory.click.ClickType; +import net.minestom.server.item.Enchantment; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class ArenaCommand extends Command { + public ArenaCommand() { + super("arena", "play", "join", "game"); + setCondition(CommandUtils::lobbyOnly); + + setDefaultExecutor((sender, context) -> open((Player) sender)); + addSyntax((sender, context) -> + play((Player) sender, context.get("type"), Set.of()), + ArgumentType.Enum("type", ArenaType.class).setFormat(ArgumentEnum.Format.LOWER_CASED)); + } + + public static void open(@NotNull Player player) { + player.openInventory(new ArenaInventory()); + } + + private static void play(@NotNull Player player, @NotNull ArenaType type, @NotNull Set options) { + if (player.getInstance() != Lobby.INSTANCE) { + Messenger.warn(player, "You are not in the lobby! Join the lobby first."); + return; + } + final Group group = Group.findGroup(player); + if (group.leader() != player) { + Messenger.warn(player, "You are not the leader of your group!"); + return; + } + + Arena arena = type.createInstance(group, options); + ArenaManager.register(arena); + arena.init().thenRun(() -> group.members().forEach(Player::refreshCommands)); + } + + private static final class ArenaInventory extends Inventory { + private static final Tag ARENA_TAG = Tag.Integer("arena").defaultValue(-1); + private static final ItemStack HEADER = ItemUtils.stripItalics(ItemStack.builder(Material.IRON_BARS) + .displayName(Component.text("Arena", NamedTextColor.RED)) + .lore(Component.text("Select an arena to play in", NamedTextColor.GRAY)) + .build()); + + ArenaInventory() { + super(InventoryType.CHEST_4_ROW, Component.text("Arena")); + + setItemStack(4, HEADER); + setItemStack(31, Items.CLOSE); + + final ArenaType[] arenaTypes = ArenaType.values(); + int index = 13 - arenaTypes.length / 2; + for (ArenaType arenaType : ArenaType.values()) + setItemStack(index++, ItemUtils.stripItalics(arenaType.item() + .withLore(List.of(Component.text( + "Left click to play or right click to configure", + NamedTextColor.GRAY + ))) + .withTag(ARENA_TAG, arenaType.ordinal()))); + + addInventoryCondition((player, slot, clickType, result) -> { + result.setCancel(true); + + if (slot == 31) { // Close button + player.closeInventory(); + return; + } + + final int arena = result.getClickedItem().getTag(ARENA_TAG); + if (arena == -1) return; + final ArenaType type = ArenaType.values()[arena]; + + if (clickType == ClickType.RIGHT_CLICK) { + player.openInventory(new ArenaOptionInventory(this, type)); + } else{ + player.closeInventory(); + play(player, type, Set.of()); + } + }); + } + } + + private static final class ArenaOptionInventory extends Inventory { + private static final ItemStack PLAY_ITEM = ItemUtils.stripItalics(ItemStack.builder(Material.NOTE_BLOCK) + .displayName(Component.text("Play", NamedTextColor.GREEN)) + .lore(Component.text("Play this arena", NamedTextColor.GRAY)) + .build()); + private static final Tag OPTION_TAG = Tag.Integer("option").defaultValue(-1); + + private final ArenaType type; + private final List availableOptions; + private final Set selectedOptions = new HashSet<>(); + + ArenaOptionInventory(@NotNull Inventory parent, @NotNull ArenaType type) { + super(InventoryType.CHEST_4_ROW, Component.text("Arena Options")); + this.type = type; + availableOptions = type.availableOptions(); + + draw(); + + addInventoryCondition((player, slot, c, result) -> { + result.setCancel(true); + + if (slot == 30) { // Play button + player.closeInventory(); + play(player, type, selectedOptions); + return; + } + + if (slot == 32) { // Back button + player.openInventory(parent); + return; + } + + final int index = result.getClickedItem().getTag(OPTION_TAG); + if (index == -1) return; + final ArenaOption option = availableOptions.get(index); + + if (!selectedOptions.add(option)) { + selectedOptions.remove(option); + } + + draw(); + }); + } + + private void draw() { + setItemStack(4, type.item()); + setItemStack(30, PLAY_ITEM); + setItemStack(32, Items.BACK); + + final int start = 13 - availableOptions.size() / 2; + int index = 0; + for (ArenaOption option : availableOptions) + setItemStack(start + index, option.item().withTag(OPTION_TAG, index++).withMeta(builder -> { + if (selectedOptions.contains(option)) builder.enchantment(Enchantment.PROTECTION, (short) 1); + })); + } + } +} diff --git a/arena/game/ArenaManager.java b/arena/game/ArenaManager.java new file mode 100644 index 0000000..c5773a4 --- /dev/null +++ b/arena/game/ArenaManager.java @@ -0,0 +1,53 @@ +package net.minestom.arena.game; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Metrics; +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnmodifiableView; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +public final class ArenaManager { + private static final List ARENAS = new CopyOnWriteArrayList<>(); + private static volatile boolean stopQueued; + + static void register(@NotNull Arena arena) { + Metrics.GAMES_IN_PROGRESS.labels(ArenaType.getMetricsDisplayName(arena)).inc(); + ARENAS.add(arena); + } + + static void unregister(@NotNull Arena arena) { + Metrics.GAMES_IN_PROGRESS.labels(ArenaType.getMetricsDisplayName(arena)).dec(); + Metrics.GAMES_PLAYED.labels(ArenaType.getMetricsDisplayName(arena)).inc(); + ARENAS.remove(arena); + if (ARENAS.size() == 0 && stopQueued) + stopServerNow(); + } + + public static @NotNull @UnmodifiableView List list() { + return Collections.unmodifiableList(ARENAS); + } + + public static void stopServer() { + Collection arenas = list(); + stopQueued = true; + for (Arena arena : arenas) arena.stop(); + if (arenas.size() == 0) stopServerNow(); + } + + private static void stopServerNow() { + for (Player player : MinecraftServer.getConnectionManager().getOnlinePlayers()) + player.kick(Component.text("Server is shutting down", NamedTextColor.RED)); + + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS) + .execute(MinecraftServer::stopCleanly); + } +} diff --git a/arena/game/ArenaOption.java b/arena/game/ArenaOption.java new file mode 100644 index 0000000..05f6095 --- /dev/null +++ b/arena/game/ArenaOption.java @@ -0,0 +1,21 @@ +package net.minestom.arena.game; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import org.jetbrains.annotations.NotNull; + +public record ArenaOption(@NotNull String name, @NotNull String description, + @NotNull TextColor color, @NotNull Material material) { + + public @NotNull ItemStack item() { + return ItemUtils.stripItalics(ItemStack.builder(material) + .displayName(Component.text(name, color)) + .lore(Component.text(description, NamedTextColor.GRAY)) + .meta(ItemUtils::hideFlags) + .build()); + } +} diff --git a/arena/game/ArenaType.java b/arena/game/ArenaType.java new file mode 100644 index 0000000..8e3cd72 --- /dev/null +++ b/arena/game/ArenaType.java @@ -0,0 +1,68 @@ +package net.minestom.arena.game; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.arena.game.mob.MobArena; +import net.minestom.arena.group.Group; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.BiFunction; + +enum ArenaType { + MOB("Mob Arena", NamedTextColor.GREEN, Material.ZOMBIE_HEAD, MobArena::new, MobArena.class, MobArena.OPTIONS); + + private final ItemStack item; + private final BiFunction, Arena> supplier; + private final List availableOptions; + + private final Class clazz; + private final String name; + private static final Map, ArenaType> classToType = new HashMap<>(); + + static { + for (ArenaType value : ArenaType.values()) { + classToType.put(value.clazz, value); + } + } + + ArenaType(@NotNull String name, @NotNull TextColor color, @NotNull Material material, + @NotNull BiFunction, Arena> supplier, @NotNull Class clazz, + @NotNull List availableOptions) { + + item = ItemUtils.stripItalics(ItemStack.builder(material) + .displayName(Component.text(name, color)) + .meta(ItemUtils::hideFlags) + .build()); + this.supplier = supplier; + this.name = name; + this.clazz = clazz; + this.availableOptions = List.copyOf(availableOptions); + } + + public ItemStack item() { + return item; + } + + public List availableOptions() { + return availableOptions; + } + + public Arena createInstance(Group group, Set options) { + return supplier.apply(group, Set.copyOf(options)); + } + + public static @Nullable ArenaType typeOf(Arena arena) { + return classToType.get(arena.getClass()); + } + + public static String getMetricsDisplayName(Arena arena) { + final ArenaType type = ArenaType.typeOf(arena); + return type == null ? "unknown" : type.name; + } +} diff --git a/arena/game/Generator.java b/arena/game/Generator.java new file mode 100644 index 0000000..9bce2bd --- /dev/null +++ b/arena/game/Generator.java @@ -0,0 +1,122 @@ +package net.minestom.arena.game; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.*; + +public sealed interface Generator permits GeneratorImpl { + @Contract("_ -> new") + static @NotNull Builder builder(@NotNull Function function) { + return new GeneratorImpl.Builder<>(function); + } + + static @NotNull List generateAll( + @NotNull List> generators, int amount, Supplier contextSupplier) { + + final Map, G> contextMap = new HashMap<>(); + final List result = new ArrayList<>(); + + for (Generator generator : generators) + contextMap.put(generator, contextSupplier.get()); + + while (result.size() < amount) { + final Generator generator = generators.get(ThreadLocalRandom.current().nextInt(generators.size())); + final G context = contextMap.get(generator); + final Optional generated = generator.generate(context); + + if (generated.isPresent()) { + result.add(generated.get()); + context.incrementGenerated(); + } + } + + return result; + } + + @NotNull Optional generate(@NotNull G context); + + sealed interface Builder permits GeneratorImpl.Builder { + @Contract("_ -> this") + @NotNull Builder chance(double chance); + + @Contract("_ -> this") + @NotNull Builder condition(@NotNull Condition condition); + + @Contract("_ -> this") + @NotNull Builder controller(@NotNull Controller controller); + + @Contract("_ -> this") + @NotNull Builder preference(@NotNull Preference preference); + + @Contract("_ -> this") + default @NotNull Builder preference(@NotNull ToDoubleFunction isPreferred) { + return preference(isPreferred, 1); + } + + @Contract("_, _ -> this") + default @NotNull Builder preference(@NotNull ToDoubleFunction isPreferred, double weight) { + return preference(new Preference<>() { + @Override + public double isPreferred(@NotNull G context) { + return isPreferred.applyAsDouble(context); + } + + @Override + public double weight() { + return weight; + } + }); + } + + @NotNull Generator build(); + } + + @FunctionalInterface + interface Condition { + boolean isMet(@NotNull G context); + } + + @FunctionalInterface + interface Controller { + @NotNull Control control(@NotNull G context); + + enum Control { + ALLOW, + DISALLOW, + OK + } + + static @NotNull Controller minCount(int count) { + return context -> context.generated() <= count + ? Control.ALLOW + : Control.OK; + } + + static @NotNull Controller minCount(ToIntFunction count) { + return context -> context.generated() <= count.applyAsInt(context) + ? Control.ALLOW + : Control.OK; + } + + static @NotNull Controller maxCount(int count) { + return context -> context.generated() >= count + ? Control.DISALLOW + : Control.OK; + } + + static @NotNull Controller maxCount(ToIntFunction count) { + return context -> context.generated() >= count.applyAsInt(context) + ? Control.DISALLOW + : Control.OK; + } + } + + interface Preference { + double isPreferred(@NotNull G context); + + double weight(); + } +} diff --git a/arena/game/GeneratorImpl.java b/arena/game/GeneratorImpl.java new file mode 100644 index 0000000..dda337a --- /dev/null +++ b/arena/game/GeneratorImpl.java @@ -0,0 +1,101 @@ +package net.minestom.arena.game; + +import net.minestom.server.utils.MathUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; +import java.util.function.Predicate; + +record GeneratorImpl(Function function, + Predicate shouldGenerate) implements Generator { + + @Override + public @NotNull Optional generate(@NotNull G context) { + return shouldGenerate.test(context) + ? Optional.of(function.apply(context)) + : Optional.empty(); + } + + static final class Builder implements Generator.Builder { + final Function function; + final List> conditions = new ArrayList<>(); + final List> controllers = new ArrayList<>(); + final List> preferences = new ArrayList<>(); + + double chance = 1; + + Builder(@NotNull Function function) { + this.function = function; + } + + @Override + public @NotNull Generator.Builder chance(double chance) { + this.chance = chance; + return this; + } + + @Override + public @NotNull Generator.Builder condition(@NotNull Condition condition) { + conditions.add(condition); + return this; + } + + @Override + public @NotNull Generator.Builder controller(@NotNull Controller controller) { + controllers.add(controller); + return this; + } + + @Override + public @NotNull Generator.Builder preference(@NotNull Preference preference) { + preferences.add(preference); + return this; + } + + @Override + public @NotNull Generator build() { + return new GeneratorImpl<>(function, context -> { + final Random random = ThreadLocalRandom.current(); + + // Chance + if (random.nextDouble() > chance) + return false; + + // Conditions + for (Condition condition : conditions) { + if (!condition.isMet(context)) + return false; + } + + // Controllers + for (Controller controller : controllers) { + switch (controller.control(context)) { + case ALLOW -> { + return true; + } + case DISALLOW -> { + return false; + } + } + } + + // Preferences + double score = 0; + for (Preference preference : preferences) { + score += MathUtils.clamp(preference.isPreferred(context), 0, 1) * preference.weight(); + } + final double chance = score / preferences.stream() + .mapToDouble(Preference::weight) + .sum(); + + // NaN if no preferences + return random.nextDouble() <= chance || Double.isNaN(chance); + }); + } + } +} diff --git a/arena/game/SingleInstanceArena.java b/arena/game/SingleInstanceArena.java new file mode 100644 index 0000000..9114119 --- /dev/null +++ b/arena/game/SingleInstanceArena.java @@ -0,0 +1,53 @@ +package net.minestom.arena.game; + +import net.minestom.arena.lobby.LobbySidebarDisplay; +import net.minestom.arena.feature.Feature; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.instance.RemoveEntityFromInstanceEvent; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface SingleInstanceArena extends Arena { + @NotNull Instance instance(); + + @NotNull Pos spawnPosition(@NotNull Player player); + + @NotNull List features(); + + @Override + default @NotNull CompletableFuture init() { + Instance instance = instance(); + // Register this arena + MinecraftServer.getInstanceManager().registerInstance(instance); + + instance.eventNode().addListener(RemoveEntityFromInstanceEvent.class, event -> { + // We don't care about entities, only players. + if (!(event.getEntity() instanceof Player)) return; + // Ensure there is only this player in the instance + if (instance.getPlayers().size() > 1) return; + // All players have left. We can remove this instance once the player is removed. + MinecraftServer.getSchedulerManager().scheduleNextTick(() -> { + MinecraftServer.getInstanceManager().unregisterInstance(instance); + stop(); + }); + + group().setDisplay(new LobbySidebarDisplay(group())); + }); + + for (Feature feature : features()) { + feature.hook(instance.eventNode()); + } + + CompletableFuture[] futures = + group().members().stream() + .map(player -> player.setInstance(instance, spawnPosition(player))) + .toArray(CompletableFuture[]::new); + + return CompletableFuture.allOf(futures).thenRun(this::start); + } +} diff --git a/arena/game/mob/ArenaClass.java b/arena/game/mob/ArenaClass.java new file mode 100644 index 0000000..2c3ac4c --- /dev/null +++ b/arena/game/mob/ArenaClass.java @@ -0,0 +1,28 @@ +package net.minestom.arena.game.mob; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; + +record ArenaClass(String name, String description, String icon, TextColor color, Material material, Kit kit, int cost) { + public void apply(Player player) { + kit.apply(player); + } + + public ItemStack itemStack() { + return ItemUtils.stripItalics(ItemStack.builder(material) + .displayName(Component.text(icon + " " + name, color)) + .lore( + Component.text(description, NamedTextColor.GRAY), + Component.empty(), + Component.text("Switch to this class for " + cost + " coins", NamedTextColor.GOLD) + ) + .meta(ItemUtils::hideFlags) + .build() + ); + } +} diff --git a/arena/game/mob/ArenaMinion.java b/arena/game/mob/ArenaMinion.java new file mode 100644 index 0000000..a400e90 --- /dev/null +++ b/arena/game/mob/ArenaMinion.java @@ -0,0 +1,17 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.entity.EntityType; +import org.jetbrains.annotations.NotNull; + +class ArenaMinion extends ArenaMob { + private final ArenaMob owner; + + public ArenaMinion(@NotNull EntityType entityType, @NotNull ArenaMob owner) { + super(entityType, owner.context); + this.owner = owner; + } + + public ArenaMob owner() { + return owner; + } +} diff --git a/arena/game/mob/ArenaUpgrade.java b/arena/game/mob/ArenaUpgrade.java new file mode 100644 index 0000000..31b4e8a --- /dev/null +++ b/arena/game/mob/ArenaUpgrade.java @@ -0,0 +1,41 @@ +package net.minestom.arena.game.mob; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.entity.Player; +import net.minestom.server.item.Enchantment; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.IntFunction; + +record ArenaUpgrade(String name, String description, TextColor color, Material material, + @Nullable BiConsumer apply, @Nullable Consumer remove, + @NotNull IntFunction effect, int cost, float costMultiplier, int maxLevel) { + public ItemStack itemStack(int level) { + return ItemUtils.stripItalics(ItemStack.builder(material) + .displayName(Component.text(name, color)) + .lore( + Component.text(description, NamedTextColor.GRAY), + Component.empty(), + Component.text("Buy this team upgrade for " + cost(level) + " coins", NamedTextColor.GOLD), + Component.text(effect.apply(level), NamedTextColor.YELLOW) + ) + .meta(ItemUtils::hideFlags) + .meta(builder -> { + if (level >= maxLevel) builder.enchantment(Enchantment.PROTECTION, (short) 1); + }) + .build() + ); + } + + public int cost(int level) { + return (int) (cost * Math.pow(costMultiplier, level)); + } +} diff --git a/arena/game/mob/BlazeMob.java b/arena/game/mob/BlazeMob.java new file mode 100644 index 0000000..cb8111b --- /dev/null +++ b/arena/game/mob/BlazeMob.java @@ -0,0 +1,122 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.*; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.List; +import java.util.function.Function; + +final class BlazeMob extends ArenaMob { + private static final int MAX_HEIGHT = 5; + + public BlazeMob(MobGenerationContext context) { + super(EntityType.BLAZE, context); + + // Custom attack goal, blaze is stationary and shouldn't follow their targets + BlazeAttackGoal rangedAttackGoal = new BlazeAttackGoal( + this, Duration.of(10, TimeUnit.SERVER_TICK), + 16, 1, 0.5, entity -> { + EntityProjectile projectile = new FireballProjectile(entity); + projectile.scheduleRemove(Duration.of(30, TimeUnit.SERVER_TICK)); + return projectile; + }); + + addAIGroup( + List.of(rangedAttackGoal), + List.of(new ClosestEntityTarget(this, 32, entity -> entity instanceof Player)) + ); + } + @Override + public @NotNull Vec getVelocity() { + return super.getVelocity() + .withY(y -> getPosition().y() > MobArena.HEIGHT + MAX_HEIGHT // If above max height + ? y / 5 // Stop flying up + : Math.abs(y)); // Fly up + } + + private static final class FireballProjectile extends EntityProjectile { + public FireballProjectile(@NotNull Entity shooter) { + super(shooter, EntityType.SMALL_FIREBALL); + setNoGravity(true); + } + + @Override + public void tick(long time) { + super.tick(time); + if (isOnGround()) remove(); + } + } + + private static final class BlazeAttackGoal extends GoalSelector { + private long lastShot; + private final Duration delay; + private final int attackRangeSquared; + private final double power; + private final double spread; + private final Function projectileGenerator; + private boolean stop; + private Entity cachedTarget; + + public BlazeAttackGoal(@NotNull EntityCreature entityCreature, Duration delay, int attackRange, double power, double spread, Function projectileGenerator) { + super(entityCreature); + this.delay = delay; + this.attackRangeSquared = attackRange * attackRange; + this.power = power; + this.spread = spread; + this.projectileGenerator = projectileGenerator; + } + + @Override + public boolean shouldStart() { + this.cachedTarget = findTarget(); + return this.cachedTarget != null; + } + + @Override + public void start() {} + + @Override + public void tick(long time) { + Entity target; + if (this.cachedTarget != null) { + target = this.cachedTarget; + this.cachedTarget = null; + } else { + target = findTarget(); + } + if (target == null) { + this.stop = true; + return; + } + double distanceSquared = this.entityCreature.getDistanceSquared(target); + if (distanceSquared <= this.attackRangeSquared) { + if (!Cooldown.hasCooldown(time, this.lastShot, this.delay)) { + if (entityCreature.hasLineOfSight(target)) { + final var to = target.getPosition().add(0D, target.getEyeHeight(), 0D); + + EntityProjectile projectile = projectileGenerator.apply(entityCreature); + projectile.setInstance(entityCreature.getInstance(), entityCreature.getPosition().add(0D, entityCreature.getEyeHeight(), 0D)); + + projectile.shoot(to, power, spread); + this.lastShot = time; + } + } + } + entityCreature.lookAt(target); + } + + @Override + public boolean shouldEnd() { + return stop; + } + + @Override + public void end() {} + } +} diff --git a/arena/game/mob/EndermanMob.java b/arena/game/mob/EndermanMob.java new file mode 100644 index 0000000..e25f055 --- /dev/null +++ b/arena/game/mob/EndermanMob.java @@ -0,0 +1,85 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.attribute.Attribute; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.goal.MeleeAttackGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.List; + +final class EndermanMob extends ArenaMob { + public EndermanMob(MobGenerationContext context) { + super(EntityType.ENDERMAN, context); + addAIGroup( + List.of( + new TeleportGoal(this, Duration.ofSeconds(10), 8), + new MeleeAttackGoal(this, 1.2, 20, TimeUnit.SERVER_TICK) + ), + List.of(new ClosestEntityTarget(this, 32, entity -> entity instanceof Player)) + ); + getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(getAttributeValue(Attribute.MOVEMENT_SPEED) * 2); + } + + private static final class TeleportGoal extends GoalSelector { + private final Duration cooldown; + private final int distanceSquared; + + private long lastTeleport; + private Entity target; + + public TeleportGoal(@NotNull EntityCreature entityCreature, @NotNull Duration cooldown, int distance) { + super(entityCreature); + this.cooldown = cooldown; + distanceSquared = distance * distance; + } + + @Override + public boolean shouldStart() { + Entity target = entityCreature.getTarget(); + if (target == null) target = findTarget(); + if (target == null) return false; + if (Cooldown.hasCooldown(System.currentTimeMillis(), lastTeleport, cooldown)) return false; + final boolean result = target.getPosition().distanceSquared(entityCreature.getPosition()) >= distanceSquared; + if (result) this.target = target; + return result; + } + + @Override + public void start() { + if (target == null) return; + entityCreature.setTarget(target); + } + + @Override + public void tick(long time) { + if (!Cooldown.hasCooldown(time, lastTeleport, cooldown)) { + final Pos targetPos = entityCreature.getTarget() != null + ? entityCreature.getTarget().getPosition() : null; + lastTeleport = time; + + if (targetPos != null) + entityCreature.teleport(targetPos.sub(targetPos.direction())); + } + } + + @Override + public boolean shouldEnd() { + final Entity target = entityCreature.getTarget(); + return target == null || target.isRemoved() || + Cooldown.hasCooldown(System.currentTimeMillis(), lastTeleport, cooldown) || + target.getPosition().distanceSquared(entityCreature.getPosition()) < distanceSquared; + } + + @Override + public void end() {} + } +} diff --git a/arena/game/mob/EvokerMob.java b/arena/game/mob/EvokerMob.java new file mode 100644 index 0000000..8c679e7 --- /dev/null +++ b/arena/game/mob/EvokerMob.java @@ -0,0 +1,111 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.attribute.Attribute; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.goal.MeleeAttackGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.metadata.monster.raider.EvokerMeta; +import net.minestom.server.entity.metadata.monster.raider.SpellcasterIllagerMeta; +import net.minestom.server.particle.Particle; +import net.minestom.server.particle.ParticleCreator; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.time.Cooldown; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; + +final class EvokerMob extends ArenaMob { + public EvokerMob(MobGenerationContext context) { + super(EntityType.EVOKER, context); + addAIGroup( + List.of(new ActionGoal(this, Duration.ofSeconds(10), target -> { + lookAt(target); + + ((EvokerMeta) getEntityMeta()).setSpell(SpellcasterIllagerMeta.Spell.SUMMON_VEX); + + scheduler().scheduleTask(() -> { + final Random random = ThreadLocalRandom.current(); + + for (int i = 0; i < random.nextInt(1, 3); i++) { + ArenaMob silverfish = new ArenaMinion(EntityType.SILVERFISH, this); + silverfish.addAIGroup( + List.of(new MeleeAttackGoal(silverfish, 1.2, 20, TimeUnit.SERVER_TICK)), + List.of(new ClosestEntityTarget(silverfish, 32, entity -> entity instanceof Player)) + ); + silverfish.getAttribute(Attribute.MAX_HEALTH).setBaseValue(silverfish.getMaxHealth() / 4); + silverfish.heal(); + final Pos pos = position.add( + random.nextFloat(-2, 2), 0, + random.nextFloat(-2, 2) + ); + silverfish.setInstance(instance, pos); + instance.sendGroupedPacket(ParticleCreator.createParticlePacket( + Particle.POOF, true, pos.x(), pos.y(), pos.z(), + 0.2f, 0.2f, 0.2f, 0.1f, 10, null + )); + } + + ((EvokerMeta) getEntityMeta()).setSpell(SpellcasterIllagerMeta.Spell.NONE); + }, TaskSchedule.seconds(2), TaskSchedule.stop()); + })), + List.of(new ClosestEntityTarget(this, 32, entity -> entity instanceof Player)) + ); + } + + private static final class ActionGoal extends GoalSelector { + private final Duration cooldown; + private final Consumer consumer; + private long lastSummon; + private Entity target; + + public ActionGoal(@NotNull EntityCreature entityCreature, @NotNull Duration cooldown, Consumer consumer) { + super(entityCreature); + this.cooldown = cooldown; + this.consumer = consumer; + } + + @Override + public boolean shouldStart() { + Entity target = entityCreature.getTarget(); + if (target == null || target.getInstance() != entityCreature.getInstance()) target = findTarget(); + if (target == null) return false; + if (Cooldown.hasCooldown(System.currentTimeMillis(), lastSummon, cooldown)) return false; + this.target = target; + return true; + } + + @Override + public void start() { + if (target == null) return; + entityCreature.setTarget(target); + } + + @Override + public void tick(long time) { + if (!Cooldown.hasCooldown(time, lastSummon, cooldown) && entityCreature.getTarget() != null) { + lastSummon = time; + consumer.accept(entityCreature.getTarget()); + } + } + + @Override + public boolean shouldEnd() { + final Entity target = entityCreature.getTarget(); + return target == null || target.isRemoved() || + Cooldown.hasCooldown(System.currentTimeMillis(), lastSummon, cooldown); + } + + @Override + public void end() {} + } +} diff --git a/arena/game/mob/Kit.java b/arena/game/mob/Kit.java new file mode 100644 index 0000000..57b40ff --- /dev/null +++ b/arena/game/mob/Kit.java @@ -0,0 +1,41 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.PlayerInventory; +import net.minestom.server.inventory.TransactionOption; +import net.minestom.server.item.ItemStack; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; + +record Kit(@NotNull List inventory, Map equipments) { + public static final Tag KIT_ITEM_TAG = Tag.Boolean("kit_item").defaultValue(false); + + Kit { + inventory = List.copyOf(inventory); + equipments = Map.copyOf(equipments); + } + + public void apply(Player player) { + final PlayerInventory playerInventory = player.getInventory(); + // Clear previous kit items + for (ItemStack item : playerInventory.getItemStacks()) { + if (item.getTag(KIT_ITEM_TAG)) + player.getInventory().takeItemStack(item, TransactionOption.ALL); + } + + // Equipment + for (EquipmentSlot slot : EquipmentSlot.armors()) { + final ItemStack item = equipments.get(slot); + if (item != null) player.setEquipment(slot, item.withTag(KIT_ITEM_TAG, true)); + else player.setEquipment(slot, ItemStack.AIR); + } + // Misc + for (ItemStack item : inventory) { + playerInventory.addItemStack(item.withTag(KIT_ITEM_TAG, true)); + } + } +} diff --git a/arena/game/mob/MobArenaInstance.java b/arena/game/mob/MobArenaInstance.java new file mode 100644 index 0000000..f30a816 --- /dev/null +++ b/arena/game/mob/MobArenaInstance.java @@ -0,0 +1,76 @@ +package net.minestom.arena.game.mob; + +import de.articdive.jnoise.core.api.pipeline.NoiseSource; +import de.articdive.jnoise.generators.noisegen.opensimplex.FastSimplexNoiseGenerator; +import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator; +import de.articdive.jnoise.modules.octavation.OctavationModule; +import de.articdive.jnoise.pipeline.JNoise; +import net.minestom.arena.Metrics; +import net.minestom.arena.utils.FullbrightDimension; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.MathUtils; + +import java.util.UUID; + +final class MobArenaInstance extends InstanceContainer { + private final JNoise noise = JNoise.newBuilder() + .fastSimplex(FastSimplexNoiseGenerator.newBuilder().build()) + .scale(0.03)//0.0025 + .octavation(OctavationModule.newBuilder().setOctaves(6).setNoiseSource(PerlinNoiseGenerator.newBuilder().build()).build()) + .build(); + + MobArenaInstance() { + super(UUID.randomUUID(), FullbrightDimension.INSTANCE); + getWorldBorder().setDiameter(100); + setGenerator(unit -> { + final Point start = unit.absoluteStart(); + for (int x = 0; x < unit.size().x(); x++) { + for (int z = 0; z < unit.size().z(); z++) { + Point bottom = start.add(x, 0, z); + // Ensure flat terrain in the fighting area + final double modifier = MathUtils.clamp((bottom.distance(Pos.ZERO.withY(bottom.y())) - 75) / 50, 0, 1); + double y = noise.evaluateNoise(bottom.x(), bottom.z()) * modifier; + y = (y > 0 ? y * 4 : y) * 8 + MobArena.HEIGHT; + unit.modifier().fill(bottom, bottom.add(1, 0, 1).withY(y), Block.SAND); + } + } + }); + + int x = MobArena.SPAWN_RADIUS; + int y = 0; + int xChange = 1 - (MobArena.SPAWN_RADIUS << 1); + int yChange = 0; + int radiusError = 0; + + while (x >= y) { + for (int i = -x; i <= x; i++) { + setBlock(i, 15, y, Block.RED_SAND); + setBlock(i, 15, -y, Block.RED_SAND); + } + for (int i = -y; i <= y; i++) { + setBlock(i, 15, x, Block.RED_SAND); + setBlock(i, 15, -x, Block.RED_SAND); + } + + y++; + radiusError += yChange; + yChange += 2; + if (((radiusError << 1) + xChange) > 0) { + x--; + radiusError += xChange; + xChange += 2; + } + } + } + + @Override + protected void setRegistered(boolean registered) { + super.setRegistered(registered); + if (!registered) { + Metrics.ENTITIES.dec(getEntities().size()); + } + } +} diff --git a/arena/game/mob/MobArenaSidebarDisplay.java b/arena/game/mob/MobArenaSidebarDisplay.java new file mode 100644 index 0000000..fa12c19 --- /dev/null +++ b/arena/game/mob/MobArenaSidebarDisplay.java @@ -0,0 +1,46 @@ +package net.minestom.arena.game.mob; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.arena.Icons; +import net.minestom.arena.Messenger; +import net.minestom.arena.group.Group; +import net.minestom.arena.group.displays.GroupSidebarDisplay; +import net.minestom.server.entity.Player; +import net.minestom.server.scoreboard.Sidebar; + +final class MobArenaSidebarDisplay extends GroupSidebarDisplay { + private final MobArena arena; + + public MobArenaSidebarDisplay(MobArena arena) { + super(arena.group()); + this.arena = arena; + } + + // TODO: Probably a better way to make the whole component gray + @Override + protected Sidebar.ScoreboardLine createPlayerLine(Player player, Group group) { + final ArenaClass arenaClass = arena.playerClass(player); + final boolean dead = !arena.instance().getPlayers().contains(player); + + Component icon = Component.text(arenaClass.icon(), arenaClass.color()); + if (!arena.stageInProgress()) { + icon = arena.hasContinued(player) + ? Component.text(Icons.CHECKMARK, dead ? NamedTextColor.GRAY : NamedTextColor.GREEN) + : Component.text(Icons.CROSS, dead ? NamedTextColor.GRAY : NamedTextColor.RED); + } + + Component line = icon.append(Component.text(" ")) + .append(player.getName().color(dead ? NamedTextColor.GRAY : Messenger.ORANGE_COLOR)); + + // Strikethrough if player is dead + if (dead) line = line.decorate(TextDecoration.STRIKETHROUGH); + + return new Sidebar.ScoreboardLine( + player.getUuid().toString(), + line, + 0 + ); + } +} diff --git a/arena/game/mob/MobTestCommand.java b/arena/game/mob/MobTestCommand.java new file mode 100644 index 0000000..582c846 --- /dev/null +++ b/arena/game/mob/MobTestCommand.java @@ -0,0 +1,101 @@ +package net.minestom.arena.game.mob; + +import net.minestom.arena.Items; +import net.minestom.arena.game.Arena; +import net.minestom.arena.game.ArenaManager; +import net.minestom.arena.utils.CommandUtils; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentLiteral; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.arguments.number.ArgumentNumber; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.utils.MathUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +public final class MobTestCommand extends Command { + public MobTestCommand() { + super("test"); + setCondition((sender, commandString) -> CommandUtils.arenaOnly(sender, commandString) && + sender instanceof Player player && player.getPermissionLevel() == 4); + + final ArgumentLiteral coins = ArgumentType.Literal("coins"); + final ArgumentLiteral stage = ArgumentType.Literal("stage"); + final ArgumentLiteral clear = ArgumentType.Literal("clear"); + final ArgumentLiteral clazz = ArgumentType.Literal("class"); + final ArgumentLiteral spawn = ArgumentType.Literal("spawn"); + final ArgumentLiteral immortal = ArgumentType.Literal("immortal"); + final ArgumentLiteral strong = ArgumentType.Literal("strong"); + final ArgumentLiteral damageme = ArgumentType.Literal("damageme"); + + final ArgumentNumber coinsAmount = ArgumentType.Integer("amount") + .between(0, 1000); + final ArgumentNumber classId = ArgumentType.Integer("id") + .between(0, MobArena.CLASSES.size() - 1); + final ArgumentNumber mobType = ArgumentType.Integer("type") + .between(0, MobArena.MOB_GENERATORS.size() - 1); + + addSyntax((sender, context) -> ((Player) sender).getInventory().addItemStack(Items.COIN + .withAmount(context.get(coinsAmount))), coins, coinsAmount); + addSyntax((sender, context) -> ((Player) sender).getInventory().addItemStack(Items.COIN + .withAmount(10)), coins); + + addSyntax((sender, context) -> arena(sender) + .ifPresent(MobArena::nextStage), stage); + + addSyntax((sender, context) -> arena(sender).ifPresent(arena -> { + for (Entity entity : arena.instance().getEntities()) { + if (entity instanceof ArenaMob arenaMob) + arenaMob.kill(); + } + }), clear); + + addSyntax((sender, context) -> arena(sender).ifPresent(arena -> + arena.setPlayerClass( + (Player) sender, + MobArena.CLASSES.get(MathUtils.clamp( + context.get(classId), + 0, MobArena.CLASSES.size() - 1 + )) + ) + ), clazz, classId); + + addSyntax((sender, context) -> arena(sender).ifPresent(arena -> + MobArena.MOB_GENERATORS.get(MathUtils.clamp( + context.get(mobType), + 0, MobArena.MOB_GENERATORS.size() - 1 + )).generate(new MobGenerationContext(arena)).ifPresent(entity -> + entity.setInstance(arena.instance(), ((Player) sender).getPosition()))), spawn, mobType); + + addSyntax((sender, context) -> { + final Player player = (Player) sender; + player.setInvulnerable(!player.isInvulnerable()); + }, immortal); + + addSyntax((sender, context) -> + ((Player) sender).getInventory().addItemStack(ItemStack.builder(Material.COOKED_CHICKEN) + .set(MobArena.MELEE_TAG, 10000).build() + ), strong); + + addSyntax((sender, context) -> ((Player) sender).damage(DamageType.VOID, 10), damageme); + } + + private static @NotNull Optional arena(CommandSender sender) { + if (!(sender instanceof Player player)) return Optional.empty(); + + for (Arena arena : ArenaManager.list()) { + if (!(arena instanceof MobArena mobArena)) continue; + + if (arena.group().members().contains(player)) + return Optional.of(mobArena); + } + + return Optional.empty(); + } +} diff --git a/arena/game/mob/NextStageInventory.java b/arena/game/mob/NextStageInventory.java new file mode 100644 index 0000000..51a13ae --- /dev/null +++ b/arena/game/mob/NextStageInventory.java @@ -0,0 +1,177 @@ +package net.minestom.arena.game.mob; + +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Items; +import net.minestom.arena.Messenger; +import net.minestom.arena.utils.ItemUtils; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.Inventory; +import net.minestom.server.inventory.InventoryType; +import net.minestom.server.inventory.TransactionOption; +import net.minestom.server.item.Enchantment; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.sound.SoundEvent; + +final class NextStageInventory extends Inventory { + private static final ItemStack HEADER = ItemUtils.stripItalics(ItemStack.builder(Material.PAPER) + .displayName(Component.text("Next Stage", NamedTextColor.GOLD)) + .lore(Component.text("Buy a different class, team upgrades or just continue to the next stage", NamedTextColor.GRAY)) + .build()); + private static final ItemStack CLASS_SELECTION = ItemUtils.stripItalics(ItemStack.builder(Material.SHIELD) + .displayName(Component.text("Class Selection", NamedTextColor.GREEN)) + .lore(Component.text("Buy a different class", NamedTextColor.GRAY)) + .build()); + private static final ItemStack TEAM_UPGRADES = ItemUtils.stripItalics(ItemStack.builder(Material.ANVIL) + .displayName(Component.text("Team Upgrades", NamedTextColor.LIGHT_PURPLE)) + .lore(Component.text("Buy upgrades for the whole team", NamedTextColor.GRAY)) + .build()); + + private final Player player; + private final MobArena arena; + + NextStageInventory(Player player, MobArena arena) { + super(InventoryType.CHEST_4_ROW, Component.text("Next Stage")); + this.player = player; + this.arena = arena; + + setItemStack(4, HEADER); + + setItemStack(12, CLASS_SELECTION); + setItemStack(14, TEAM_UPGRADES); + + setItemStack(30, Items.CLOSE); + setItemStack(32, Items.CONTINUE); + + addInventoryCondition((p, s, c, result) -> result.setCancel(true)); + addInventoryCondition((p, slot, c, r) -> { + switch (slot) { + case 12 -> player.openInventory(new ClassSelectionInventory(this)); + case 14 -> player.openInventory(new TeamUpgradeInventory(this)); + case 30 -> player.closeInventory(); + case 32 -> { + player.closeInventory(); + arena.continueToNextStage(player); + } + } + }); + } + + private final class ClassSelectionInventory extends Inventory { + ClassSelectionInventory(Inventory parent) { + super(InventoryType.CHEST_4_ROW, Component.text("Class Selection")); + + setItemStack(4, HEADER); + + draw(); + + setItemStack(31, Items.BACK); + + addInventoryCondition((p, s, c, result) -> result.setCancel(true)); + addInventoryCondition((p, slot, c, r) -> { + if (slot == 31) player.openInventory(parent); + else { + final int length = MobArena.CLASSES.size(); + for (int i = 0; i < length; i++) { + ArenaClass arenaClass = MobArena.CLASSES.get(i); + + if (slot == 13 - length / 2 + i) { + switchClass(arenaClass); + return; + } + } + } + }); + } + + private void draw() { + final int length = MobArena.CLASSES.size(); + for (int i = 0; i < length; i++) { + ArenaClass arenaClass = MobArena.CLASSES.get(i); + + setItemStack(13 - length / 2 + i, arenaClass.itemStack() + .withMeta(builder -> { + if (arena.playerClass(player).equals(arenaClass)) + builder.enchantment(Enchantment.PROTECTION, (short) 1); + })); + } + } + + private void switchClass(ArenaClass arenaClass) { + if (arena.playerClass(player).equals(arenaClass)) { + Messenger.warn(player, "You can't switch to your selected class"); + return; + } + + if (player.getInventory().takeItemStack(Items.COIN.withAmount(arenaClass.cost()), TransactionOption.ALL_OR_NOTHING)) { + Messenger.info(arena.group(), player.getUsername() + " switched their class to " + arenaClass.name()); + arena.setPlayerClass(player, arenaClass); + draw(); + player.playSound(Sound.sound(SoundEvent.ENTITY_VILLAGER_YES, Sound.Source.NEUTRAL, 1, 1), Sound.Emitter.self()); + } else { + Messenger.warn(player, "You can't afford that"); + player.playSound(Sound.sound(SoundEvent.ENTITY_VILLAGER_NO, Sound.Source.NEUTRAL, 1, 1), Sound.Emitter.self()); + } + } + } + + private final class TeamUpgradeInventory extends Inventory { + TeamUpgradeInventory(Inventory parent) { + super(InventoryType.CHEST_4_ROW, Component.text("Team Upgrades")); + + setItemStack(4, HEADER); + + draw(); + + setItemStack(31, Items.BACK); + + addInventoryCondition((p, s, c, result) -> result.setCancel(true)); + addInventoryCondition((p, slot, c, r) -> { + if (slot == 31) player.openInventory(parent); + else { + final int length = MobArena.UPGRADES.size(); + for (int i = 0; i < length; i++) { + ArenaUpgrade upgrade = MobArena.UPGRADES.get(i); + + if (slot == 13 - length / 2 + i) { + buyUpgrade(upgrade); + return; + } + } + } + }); + } + + private void draw() { + final int length = MobArena.UPGRADES.size(); + for (int i = 0; i < length; i++) { + final ArenaUpgrade upgrade = MobArena.UPGRADES.get(i); + final int level = arena.getUpgrade(upgrade); + + setItemStack(13 - length / 2 + i, upgrade.itemStack(level)); + } + } + + private void buyUpgrade(ArenaUpgrade upgrade) { + final int level = arena.getUpgrade(upgrade); + + if (level >= upgrade.maxLevel()) { + Messenger.warn(player, "Maximum upgrade level has been reached"); + player.playSound(Sound.sound(SoundEvent.ENTITY_VILLAGER_NO, Sound.Source.NEUTRAL, 1, 1), Sound.Emitter.self()); + return; + } + + if (player.getInventory().takeItemStack(Items.COIN.withAmount(upgrade.cost(level)), TransactionOption.ALL_OR_NOTHING)) { + Messenger.info(arena.group(), player.getUsername() + " bought the " + upgrade.name() + " upgrade"); + arena.addUpgrade(upgrade); + draw(); + player.playSound(Sound.sound(SoundEvent.ENTITY_VILLAGER_YES, Sound.Source.NEUTRAL, 1, 1), Sound.Emitter.self()); + } else { + Messenger.warn(player, "You can't afford that"); + player.playSound(Sound.sound(SoundEvent.ENTITY_VILLAGER_NO, Sound.Source.NEUTRAL, 1, 1), Sound.Emitter.self()); + } + } + } +} diff --git a/arena/game/mob/NextStageNPC.java b/arena/game/mob/NextStageNPC.java new file mode 100644 index 0000000..e3378e8 --- /dev/null +++ b/arena/game/mob/NextStageNPC.java @@ -0,0 +1,10 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; + +final class NextStageNPC extends Entity { + public NextStageNPC() { + super(EntityType.VILLAGER); + } +} diff --git a/arena/game/mob/SkeletonMob.java b/arena/game/mob/SkeletonMob.java new file mode 100644 index 0000000..d2accdd --- /dev/null +++ b/arena/game/mob/SkeletonMob.java @@ -0,0 +1,65 @@ +package net.minestom.arena.game.mob; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.ai.goal.RangedAttackGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.List; + +final class SkeletonMob extends ArenaMob { + public SkeletonMob(MobGenerationContext context) { + super(EntityType.SKELETON, context); + setItemInMainHand(ItemStack.of(Material.BOW)); + + RangedAttackGoal rangedAttackGoal = new RangedAttackGoal( + this, Duration.of(40, TimeUnit.SERVER_TICK), + 16, 8, true, 1, 0.1); + + rangedAttackGoal.setProjectileGenerator(entity -> { + HomingArrow projectile = new HomingArrow(entity, EntityType.PLAYER); + projectile.scheduleRemove(Duration.of(100, TimeUnit.SERVER_TICK)); + return projectile; + }); + + addAIGroup( + List.of(rangedAttackGoal), + List.of(new ClosestEntityTarget(this, 32, entity -> entity instanceof Player)) + ); + } + + private static final class HomingArrow extends EntityProjectile { + private final EntityType target; + + public HomingArrow(@Nullable Entity shooter, EntityType target) { + super(shooter, EntityType.ARROW); + this.target = target; + } + + @Override + public void tick(long time) { + super.tick(time); + if (instance == null) return; + if (isOnGround()) return; + + for (Entity entity : instance.getNearbyEntities(position, 5.0)) { + if (entity.getEntityType() != target) continue; + + final Vec target = position.withLookAt(entity.getPosition()).direction(); + final Vec newVelocity = velocity.add(target); + + setVelocity(newVelocity); + + return; + } + } + } +} diff --git a/arena/game/mob/SpiderMob.java b/arena/game/mob/SpiderMob.java new file mode 100644 index 0000000..34df9d4 --- /dev/null +++ b/arena/game/mob/SpiderMob.java @@ -0,0 +1,110 @@ +package net.minestom.arena.game.mob; + +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.*; +import net.minestom.server.entity.ai.goal.RangedAttackGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.metadata.other.ArmorStandMeta; +import net.minestom.server.event.entity.projectile.ProjectileCollideWithBlockEvent; +import net.minestom.server.event.entity.projectile.ProjectileCollideWithEntityEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.network.packet.server.LazyPacket; +import net.minestom.server.network.packet.server.play.EntityEquipmentPacket; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +final class SpiderMob extends ArenaMob { + public SpiderMob(MobGenerationContext context) { + super(EntityType.SPIDER, context); + + RangedAttackGoal attackGoal = new RangedAttackGoal( + this, Duration.of(10, TimeUnit.SECOND), + 16, 12, false, 1.3, 0); + + attackGoal.setProjectileGenerator(WebProjectile::new); + + addAIGroup( + List.of(attackGoal), + List.of(new ClosestEntityTarget(this, 32, entity -> entity instanceof Player)) + ); + } + + private static class WebProjectile extends EntityProjectile { + public WebProjectile(@NotNull Entity shooter) { + super(shooter, EntityType.ARMOR_STAND); + ArmorStandMeta meta = (ArmorStandMeta) getEntityMeta(); + meta.setHasNoBasePlate(true); + meta.setHasArms(true); + meta.setSmall(true); + meta.setRightArmRotation(new Vec(135, 90, 0)); + meta.setInvisible(true); + getViewersAsAudience().playSound(Sound.sound(SoundEvent.ENTITY_SPIDER_STEP, Sound.Source.HOSTILE, 1, 1), shooter); + + eventNode().addListener(ProjectileCollideWithEntityEvent.class, event -> { + final Entity target = event.getTarget(); + if (!(target instanceof Player)) event.setCancelled(true); + else collide(event.getEntity(), target.getPosition()); + }); + eventNode().addListener(ProjectileCollideWithBlockEvent.class, event -> collide(event.getEntity(), event.getCollisionPosition())); + } + + private @NotNull EntityEquipmentPacket getEquipmentsPacket() { + return new EntityEquipmentPacket(this.getEntityId(), Map.of( + EquipmentSlot.MAIN_HAND, ItemStack.of(Material.COBWEB), + EquipmentSlot.OFF_HAND, ItemStack.AIR, + EquipmentSlot.BOOTS, ItemStack.AIR, + EquipmentSlot.LEGGINGS, ItemStack.AIR, + EquipmentSlot.CHESTPLATE, ItemStack.AIR, + EquipmentSlot.HELMET, ItemStack.AIR)); + } + + @Override + public void updateNewViewer(@NotNull Player player) { + super.updateNewViewer(player); + player.sendPacket(new LazyPacket(this::getEquipmentsPacket)); + } + + private static void collide(Entity projectile, Pos pos) { + final Instance instance = projectile.getInstance(); + if (instance == null) return; + + final Random random = ThreadLocalRandom.current(); + final List cobwebs = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + Pos spawnAt = pos.add( + random.nextInt(-1, 1), + random.nextInt(0, 2), + random.nextInt(-1, 1) + ); + + if (instance.getBlock(spawnAt).isAir()) { + instance.setBlock(spawnAt, Block.COBWEB); + cobwebs.add(spawnAt); + } + } + + projectile.remove(); + + instance.scheduler().buildTask(() -> { + if (!instance.isRegistered()) return; + + for (Pos cobweb : cobwebs) { + instance.setBlock(cobweb, Block.AIR); + } + }).delay(5, TimeUnit.SECOND).schedule(); + } + } +} diff --git a/arena/group/Group.java b/arena/group/Group.java new file mode 100644 index 0000000..0ce338b --- /dev/null +++ b/arena/group/Group.java @@ -0,0 +1,29 @@ +package net.minestom.arena.group; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.audience.ForwardingAudience; +import net.minestom.arena.group.displays.GroupDisplay; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Set; + +public sealed interface Group extends ForwardingAudience permits GroupImpl { + static Group findGroup(@NotNull Player player) { + return GroupManager.getGroup(player); + } + + @NotNull Player leader(); + + @NotNull List<@NotNull Player> members(); + + @NotNull GroupDisplay display(); + + void setDisplay(@NotNull GroupDisplay display); + + @Override + default @NotNull Iterable audiences() { + return members(); + } +} diff --git a/arena/group/GroupCommand.java b/arena/group/GroupCommand.java new file mode 100644 index 0000000..0a180e5 --- /dev/null +++ b/arena/group/GroupCommand.java @@ -0,0 +1,168 @@ +package net.minestom.arena.group; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.utils.CommandUtils; +import net.minestom.arena.Messenger; +import net.minestom.server.command.builder.Command; +import net.minestom.server.entity.Player; +import net.minestom.server.utils.entity.EntityFinder; + +import java.util.StringJoiner; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Entity; +import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; + +public final class GroupCommand extends Command { + public GroupCommand() { + super("group"); + setCondition(CommandUtils::lobbyOnly); + + addSyntax((sender, context) -> { + Player player = (Player) sender; + GroupImpl group = GroupManager.getMemberGroup(player); + + if (group == null || group.members().size() == 1) { + Messenger.info(sender, Component.text("You aren't in a group. Begin by inviting another player (click).") + .clickEvent(ClickEvent.suggestCommand("/group invite ")) + .hoverEvent(HoverEvent.showText(Component.text("/group invite ", NamedTextColor.GREEN))) + ); + } else { + boolean isLeader = group.leader().equals(player); + TextComponent.Builder builder = Component.text() + .append(Component.text((isLeader ? "Your" : group.leader().getUsername() + "'s") + " group")) + .append(Component.newline()) + .append(Component.text("Members (" + group.members().size() + "): ")); + + StringJoiner joiner = new StringJoiner(", "); + for (Player member : group.members()) { + joiner.add(member.getUsername()); + } + builder.append(Component.text(joiner.toString())); + + Messenger.info(sender, builder.build()); + } + }); + + addSyntax((sender, context) -> + GroupManager.removePlayer((Player) sender), Literal("leave")); + + addSyntax((sender, context) -> { + final EntityFinder finder = context.get("player"); + final Player player = finder.findFirstPlayer(sender); + + Player inviter = (Player) sender; + if (player != null) { + GroupImpl group = GroupManager.getMemberGroup(inviter); + + if (group == null) { + group = GroupManager.getGroup(inviter); + Messenger.info(sender, "Group created"); + } else if (group.members().contains(player)) { + Messenger.warn(sender, player.getName().append(Component.text(" is already in this group."))); + } + + if (!group.members().contains(player) && !group.getPendingInvites().contains(player)) { + Component invite = group.getInviteMessage(); + group.addPendingInvite(player); + Messenger.info(player, invite); + Messenger.info(inviter, Component.text("Invite sent to ").append(player.getName())); + } + } else { + Messenger.warn(sender, "Player not found"); + } + }, Literal("invite"), Entity("player").onlyPlayers(true).singleEntity(true)); + + addSyntax((sender, context) -> { + final EntityFinder finder = context.get("player"); + final Player newLeader = finder.findFirstPlayer(sender); + + Player player = (Player) sender; + if (newLeader != null) { + GroupImpl group = GroupManager.getMemberGroup(player); + + if (group == null) { + GroupManager.getGroup(newLeader); + Messenger.info(sender, "Group created"); + } else if (group.leader() != player) { + Messenger.warn(sender, "You are not the leader of this group"); + } else if (group.leader() == newLeader) { + Messenger.warn(sender, "You are already the leader"); + } else { + group.setLeader(newLeader); + } + } else { + Messenger.warn(sender, "Player not found"); + } + // TODO: only show players in the group + }, Literal("leader"), Entity("player").onlyPlayers(true).singleEntity(true)); + + addSyntax((sender, context) -> { + final EntityFinder finder = context.get("player"); + final Player toKick = finder.findFirstPlayer(sender); + + Player player = (Player) sender; + if (toKick != null) { + GroupImpl group = GroupManager.getMemberGroup(player); + + if (group == null) { + Messenger.warn(sender, "You are not in a group"); + } else if (group.leader() == player) { + if (toKick == player) { + GroupManager.removePlayer(player); + } else { + if (group.removeMember(toKick)) { + group.members().forEach(p -> { + if (p == player) { + Messenger.info(p, Component.text("You kicked ") + .append(toKick.getName()).append(Component.text(" from the group"))); + } else { + Messenger.info(p, toKick.getName().append(Component.text(" was kicked from your group"))); + } + }); + + Messenger.info(toKick, Component.text("You have been kicked rom ") + .append(player.getName()) + .append(Component.text("'s group"))); + } else { + Messenger.warn(sender, "They are not in your group"); + } + + } + } else { + Messenger.warn(player, "You are not the leader of this group"); + } + } else { + Messenger.warn(sender, "Player not found"); + } + // TODO: only show players in the group + }, Literal("kick"), Entity("player").onlyPlayers(true).singleEntity(true)); + + addSyntax((sender, context) -> { + final EntityFinder finder = context.get("player"); + final Player player = finder.findFirstPlayer(sender); + if (player != null) { + GroupImpl group = GroupManager.getGroup(player); + + Player invitee = (Player) sender; + boolean wasInvited = group.getPendingInvites().contains(invitee); + if (wasInvited) { + GroupManager.removePlayer(invitee); // Remove from old group + group.addMember(invitee); + Component accepted = group.getAcceptedMessage(); + Messenger.info(invitee, accepted); + } else if (group.members().contains(invitee)) { + Messenger.warn(invitee, "You are already in this group"); + } else { + Messenger.warn(invitee, Component.text("You have not been invited to ") + .append(group.leader().getName()).append(Component.text("'s group"))); + } + } else { + Messenger.warn(sender, "Group not found"); + } + }, Literal("accept"), Entity("player").onlyPlayers(true).singleEntity(true)); + } +} diff --git a/arena/group/GroupEvent.java b/arena/group/GroupEvent.java new file mode 100644 index 0000000..41052ae --- /dev/null +++ b/arena/group/GroupEvent.java @@ -0,0 +1,12 @@ +package net.minestom.arena.group; + +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import org.jetbrains.annotations.NotNull; + +public class GroupEvent { + public static void hook(@NotNull EventNode eventHandler) { + eventHandler.addListener(PlayerDisconnectEvent.class, event -> GroupManager.removePlayer(event.getPlayer())); + } +} diff --git a/arena/group/GroupImpl.java b/arena/group/GroupImpl.java new file mode 100644 index 0000000..cc91bd8 --- /dev/null +++ b/arena/group/GroupImpl.java @@ -0,0 +1,99 @@ +package net.minestom.arena.group; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Messenger; +import net.minestom.arena.group.displays.GroupDisplay; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; + +final class GroupImpl implements Group { + private final List players = new ArrayList<>(); + private final Set pendingInvites = Collections.newSetFromMap(new WeakHashMap<>()); + + private Player leader; + private GroupDisplay display; + + @Override + public @NotNull Player leader() { + return leader; + } + + GroupImpl(@NotNull Player leader) { + this.leader = leader; + players.add(leader); + } + + @Override + public @NotNull List members() { + return List.copyOf(players); + } + + @Override + public @NotNull GroupDisplay display() { + return display; + } + + @Override + public void setDisplay(@NotNull GroupDisplay display) { + if (this.display != null) this.display.clean(); + this.display = display; + display.update(); + } + + public void addPendingInvite(@NotNull Player player) { + pendingInvites.add(player); + } + + public @NotNull Set getPendingInvites() { + return pendingInvites; + } + + public void addMember(@NotNull Player player) { + if (players.add(player)) { + pendingInvites.remove(player); + players.forEach(p -> { + if (p != player) { + Messenger.info(p, player.getName().append(Component.text(" has joined your group"))); + } + }); + display.update(); + } + } + + public boolean removeMember(@NotNull Player player) { + if (players.remove(player)) { + players.forEach(p -> Messenger.info(p, player.getName().append(Component.text(" has left your group")))); + display.update(); + return true; + } + return false; + } + + public @NotNull Component getInviteMessage() { + return leader.getName() + .append(Component.text(" Has invited you to join their group. ")) + .append(Component.text("[Accept]").color(NamedTextColor.GREEN).clickEvent( + ClickEvent.clickEvent(ClickEvent.Action.RUN_COMMAND, "/group accept " + leader.getUsername()) + )); + } + + public Component getAcceptedMessage() { + return Component.text("You have been added to ") + .append(leader.getName()) + .append(Component.text("'s group")); + } + + public void setLeader(@NotNull Player player) { + this.leader = player; + players.forEach(p -> Messenger.info(p, player.getName().append(Component.text(" has become the group leader")))); + display.update(); + } +} diff --git a/arena/group/GroupManager.java b/arena/group/GroupManager.java new file mode 100644 index 0000000..9d7c5eb --- /dev/null +++ b/arena/group/GroupManager.java @@ -0,0 +1,63 @@ +package net.minestom.arena.group; + +import net.minestom.arena.lobby.LobbySidebarDisplay; +import net.minestom.arena.Messenger; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public final class GroupManager { + private static final Map groups = new HashMap<>(); + + public static @NotNull GroupImpl getGroup(@NotNull Player player) { + GroupImpl group = groups.get(player); + if (group == null) group = createGroup(player); + return group; + } + + public static @NotNull GroupImpl createGroup(@NotNull Player player) { + GroupImpl group = new GroupImpl(player); + group.setDisplay(new LobbySidebarDisplay(group)); + groups.put(player, group); + return group; + } + + public static void transferOwnership(@NotNull GroupImpl group, @NotNull Player newLeader) { + groups.remove(group.leader()); + group.setLeader(newLeader); + Messenger.info(group, "Group ownership has been transferred to " + newLeader.getUsername()); + groups.put(newLeader, group); + } + + public static void removePlayer(@NotNull Player player) { + GroupImpl foundGroup = getMemberGroup(player); + if (foundGroup == null) return; + + foundGroup.removeMember(player); + + // If the leader is removed, change the leader + if (groups.containsKey(player)) { + Optional newLeader = groups.get(player).members().stream().findFirst(); + + if (newLeader.isPresent()) { + transferOwnership(groups.get(player), newLeader.get()); + Messenger.info(player, "You have left your group and ownership has been transferred"); + } else { + Messenger.info(player, "Your group has been disbanded"); + groups.remove(player); + } + } else { + Messenger.info(player, "You have left your group"); + } + } + + public static GroupImpl getMemberGroup(@NotNull Player player) { + for (GroupImpl group : groups.values()) + if (group.members().contains(player)) + return group; + return null; + } +} diff --git a/arena/group/displays/GroupDisplay.java b/arena/group/displays/GroupDisplay.java new file mode 100644 index 0000000..a439ada --- /dev/null +++ b/arena/group/displays/GroupDisplay.java @@ -0,0 +1,6 @@ +package net.minestom.arena.group.displays; + +public interface GroupDisplay { + void update(); + default void clean() {} +} diff --git a/arena/group/displays/GroupSidebarDisplay.java b/arena/group/displays/GroupSidebarDisplay.java new file mode 100644 index 0000000..0e5cece --- /dev/null +++ b/arena/group/displays/GroupSidebarDisplay.java @@ -0,0 +1,88 @@ +package net.minestom.arena.group.displays; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Messenger; +import net.minestom.arena.group.Group; +import net.minestom.server.entity.Player; +import net.minestom.server.scoreboard.Sidebar; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public abstract class GroupSidebarDisplay implements GroupDisplay { + private static final int MAX_SCOREBOARD_LINES = 15; + + private final Sidebar sidebar = new Sidebar(Component.text("Group")); + private final Group group; + + public GroupSidebarDisplay(Group group) { + this.group = group; + } + + private List createLines() { + List lines = new ArrayList<>(); + + List groupMembers = group.members(); + // separate check is required to prevent "1 more..." from occurring when the player could just be displayed. + if (groupMembers.size() <= MAX_SCOREBOARD_LINES) { + for (Player player : groupMembers) { + lines.add(createPlayerLine(player, group)); + } + } else { + for (int i = 0; i < groupMembers.size() && i < 14; i++) { + lines.add(createPlayerLine(groupMembers.get(i), group)); + } + lines.add(new Sidebar.ScoreboardLine( + "more", + Component.text(groupMembers.size() - 14 + " more...", NamedTextColor.DARK_GREEN), + -1 + )); + } + + lines.addAll(createAdditionalLines()); + + return lines; + } + + protected abstract Sidebar.ScoreboardLine createPlayerLine(Player player, Group group); + + protected List createAdditionalLines() { + return List.of(); + } + + @Override + public final void update() { + Set lines = sidebar.getLines(); + for (Sidebar.ScoreboardLine line : lines) { + sidebar.removeLine(line.getId()); + } + + Set toUpdate = new HashSet<>(group.members()); + toUpdate.retainAll(sidebar.getPlayers()); + + Set toRemove = new HashSet<>(sidebar.getPlayers()); + toRemove.removeAll(toUpdate); + for (Player player : toRemove) { + sidebar.removeViewer(player); + } + + for (Sidebar.ScoreboardLine line : createLines()) + sidebar.createLine(line); + + Set toAdd = new HashSet<>(group.members()); + toAdd.removeAll(sidebar.getPlayers()); + for (Player player : toAdd) { + sidebar.addViewer(player); + } + } + + @Override + public void clean() { + for (Player viewer : sidebar.getViewers()) { + sidebar.removeViewer(viewer); + } + } +} diff --git a/arena/lobby/Lobby.java b/arena/lobby/Lobby.java new file mode 100644 index 0000000..372722b --- /dev/null +++ b/arena/lobby/Lobby.java @@ -0,0 +1,55 @@ +package net.minestom.arena.lobby; + +import net.minestom.arena.group.Group; +import net.minestom.arena.utils.FullbrightDimension; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.instance.AddEntityToInstanceEvent; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.player.PlayerEntityInteractEvent; +import net.minestom.server.instance.AnvilLoader; +import net.minestom.server.instance.Instance; +import org.jglrxavpok.hephaistos.nbt.NBTCompound; + +import java.nio.file.Path; + +public final class Lobby { + public static final Instance INSTANCE; + + static { + final Instance instance = MinecraftServer.getInstanceManager().createInstanceContainer( + FullbrightDimension.INSTANCE, new AnvilLoader(Path.of("lobby"))); + + Map.create(instance, new Pos(2, 18, 9)); + instance.setTimeRate(0); + for (NPC npc : NPC.spawnNPCs(instance)) { + instance.eventNode().addListener(EntityAttackEvent.class, npc::handle) + .addListener(PlayerEntityInteractEvent.class, npc::handle); + } + + instance.eventNode().addListener(AddEntityToInstanceEvent.class, event -> { + if (!(event.getEntity() instanceof Player player)) return; + + if (player.getInstance() != null) player.scheduler().scheduleNextTick(() -> onArenaFinish(player)); + else onFirstSpawn(player); + }).addListener(ItemDropEvent.class, event -> event.setCancelled(true)); + + INSTANCE = instance; + } + + private static void onFirstSpawn(Player player) { + player.sendPackets(Map.packets()); + + final Group group = Group.findGroup(player); + group.setDisplay(new LobbySidebarDisplay(group)); + } + + private static void onArenaFinish(Player player) { + player.refreshCommands(); + player.getInventory().clear(); + player.teleport(new Pos(0.5, 16, 0.5)); + player.tagHandler().updateContent(NBTCompound.EMPTY); + } +} diff --git a/arena/lobby/LobbySidebarDisplay.java b/arena/lobby/LobbySidebarDisplay.java new file mode 100644 index 0000000..e767732 --- /dev/null +++ b/arena/lobby/LobbySidebarDisplay.java @@ -0,0 +1,33 @@ +package net.minestom.arena.lobby; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.arena.Icons; +import net.minestom.arena.Messenger; +import net.minestom.arena.group.Group; +import net.minestom.arena.group.displays.GroupSidebarDisplay; +import net.minestom.server.entity.Player; +import net.minestom.server.scoreboard.Sidebar; + +public final class LobbySidebarDisplay extends GroupSidebarDisplay { + public LobbySidebarDisplay(Group group) { + super(group); + } + + @Override + protected Sidebar.ScoreboardLine createPlayerLine(Player player, Group group) { + if (player.equals(group.leader())) { + return new Sidebar.ScoreboardLine( + player.getUuid().toString(), + Component.text(Icons.STAR + " ").color(NamedTextColor.WHITE).append(player.getName().color(Messenger.ORANGE_COLOR)), + 1 + ); + } else { + return new Sidebar.ScoreboardLine( + player.getUuid().toString(), + player.getName(), + 0 + ); + } + } +} diff --git a/arena/lobby/Map.java b/arena/lobby/Map.java new file mode 100644 index 0000000..768dbc7 --- /dev/null +++ b/arena/lobby/Map.java @@ -0,0 +1,82 @@ +package net.minestom.arena.lobby; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.metadata.other.ItemFrameMeta; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.item.metadata.MapMeta; +import net.minestom.server.map.framebuffers.LargeGraphics2DFramebuffer; +import net.minestom.server.network.packet.server.SendablePacket; +import org.jetbrains.annotations.NotNull; + +import javax.imageio.ImageIO; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; + +final class Map { + private static SendablePacket[] packets = null; + + private Map() {} + + public static SendablePacket[] packets() { + if (packets != null) return packets; + + try { + final LargeGraphics2DFramebuffer framebuffer = new LargeGraphics2DFramebuffer(5 * 128, 3 * 128); + final InputStream imageStream = Lobby.class.getResourceAsStream("/minestom.png"); + assert imageStream != null; + BufferedImage image = ImageIO.read(imageStream); + framebuffer.getRenderer().drawRenderedImage(image, AffineTransform.getScaleInstance(1.0, 1.0)); + packets = mapPackets(framebuffer); + + return packets; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Creates the maps on the board in the lobby + */ + public static void create(@NotNull Instance instance, Point maximum) { + final int maxX = maximum.blockX(); + final int maxY = maximum.blockY(); + final int z = maximum.blockZ(); + for (int i = 0; i < 15; i++) { + final int x = maxX - i % 5; + final int y = maxY - i / 5; + final int id = i; + + final Entity itemFrame = new Entity(EntityType.ITEM_FRAME); + final ItemFrameMeta meta = (ItemFrameMeta) itemFrame.getEntityMeta(); + itemFrame.setInstance(instance, new Pos(x, y, z, 180, 0)); + meta.setNotifyAboutChanges(false); + meta.setOrientation(ItemFrameMeta.Orientation.NORTH); + meta.setInvisible(true); + meta.setItem(ItemStack.builder(Material.FILLED_MAP) + .meta(MapMeta.class, builder -> builder.mapId(id)) + .build()); + meta.setNotifyAboutChanges(true); + } + } + + /** + * Creates packets for maps that will display an image on the board in the lobby + */ + private static SendablePacket[] mapPackets(@NotNull LargeGraphics2DFramebuffer framebuffer) { + final SendablePacket[] packets = new SendablePacket[15]; + for (int i = 0; i < 15; i++) { + final int x = i % 5; + final int y = i / 5; + packets[i] = framebuffer.createSubView(x * 128, y * 128).preparePacket(i); + } + + return packets; + } +} diff --git a/arena/lobby/NPC.java b/arena/lobby/NPC.java new file mode 100644 index 0000000..1e7356a --- /dev/null +++ b/arena/lobby/NPC.java @@ -0,0 +1,167 @@ +package net.minestom.arena.lobby; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.minestom.arena.Messenger; +import net.minestom.arena.game.ArenaCommand; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.*; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.metadata.PlayerMeta; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.player.PlayerEntityInteractEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket; +import net.minestom.server.sound.SoundEvent; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.function.Consumer; + +// https://gist.github.com/iam4722202468/36630043ca89e786bb6318e296f822f8 +final class NPC extends EntityCreature { + private final String name; + private final PlayerSkin skin; + private final Consumer onClick; + + NPC(@NotNull String name, @NotNull PlayerSkin skin, @NotNull Instance instance, + @NotNull Point spawn, @NotNull Consumer onClick) { + + super(EntityType.PLAYER); + this.name = name; + this.skin = skin; + this.onClick = onClick; + + final PlayerMeta meta = (PlayerMeta) getEntityMeta(); + meta.setNotifyAboutChanges(false); + meta.setCapeEnabled(false); + meta.setJacketEnabled(true); + meta.setLeftSleeveEnabled(true); + meta.setRightSleeveEnabled(true); + meta.setLeftLegEnabled(true); + meta.setRightLegEnabled(true); + meta.setHatEnabled(true); + meta.setNotifyAboutChanges(true); + + addAIGroup( + List.of(new LookAtPlayerGoal(this)), + List.of(new ClosestEntityTarget(this, 15, entity -> entity instanceof Player)) + ); + + setInstance(instance, spawn); + } + + public void handle(@NotNull EntityAttackEvent event) { + if (event.getTarget() != this) return; + if (!(event.getEntity() instanceof Player player)) return; + + player.playSound(Sound.sound() + .type(SoundEvent.BLOCK_NOTE_BLOCK_PLING) + .pitch(2) + .build(), event.getTarget()); + onClick.accept(player); + } + + public void handle(@NotNull PlayerEntityInteractEvent event) { + if (event.getTarget() != this) return; + if (event.getHand() != Player.Hand.MAIN) return; // Prevent duplicating event + + event.getEntity().playSound(Sound.sound() + .type(SoundEvent.BLOCK_NOTE_BLOCK_PLING) + .pitch(2) + .build(), event.getTarget()); + onClick.accept(event.getEntity()); + } + + @Override + public void updateNewViewer(@NotNull Player player) { + // Required to spawn player + final List properties = List.of( + new PlayerInfoUpdatePacket.Property("textures", skin.textures(), skin.signature()) + ); + player.sendPacket(new PlayerInfoUpdatePacket(PlayerInfoUpdatePacket.Action.ADD_PLAYER, + new PlayerInfoUpdatePacket.Entry( + getUuid(), name, properties, false, 0, GameMode.SURVIVAL, null, + null) + ) + ); + + super.updateNewViewer(player); + } + + private static final class LookAtPlayerGoal extends GoalSelector { + private Entity target; + + public LookAtPlayerGoal(EntityCreature entityCreature) { + super(entityCreature); + } + + @Override + public boolean shouldStart() { + target = findTarget(); + return target != null; + } + + @Override + public void start() {} + + @Override + public void tick(long time) { + if (entityCreature.getDistanceSquared(target) > 225 || + entityCreature.getInstance() != target.getInstance()) { + target = null; + return; + } + + entityCreature.lookAt(target); + } + + @Override + public boolean shouldEnd() { + return target == null; + } + + @Override + public void end() {} + } + + public static List spawnNPCs(@NotNull Instance instance) { + try { + final java.util.Map skins = new HashMap<>(); + final Gson gson = new Gson(); + final JsonObject root = gson.fromJson(new String(Lobby.class.getResourceAsStream("/skins.json") + .readAllBytes()), JsonObject.class); + + for (JsonElement skin : root.getAsJsonArray("skins")) { + final JsonObject object = skin.getAsJsonObject(); + final String owner = object.get("owner").getAsString(); + final String value = object.get("value").getAsString(); + final String signature = object.get("signature").getAsString(); + skins.put(owner, new PlayerSkin(value, signature)); + } + + return List.of( + new NPC("Discord", skins.get("Discord"), instance, new Pos(8.5, 15, 8.5), + player -> Messenger.info(player, Component.text("Click here to join the Discord server") + .clickEvent(ClickEvent.openUrl("https://discord.gg/minestom")))), + new NPC("Website", skins.get("Website"), instance, new Pos(-7.5, 15, 8.5), + player -> Messenger.info(player, Component.text("Click here to go to the Minestom website") + .clickEvent(ClickEvent.openUrl("https://minestom.net")))), + new NPC("GitHub", skins.get("GitHub"), instance, new Pos(8.5, 15, -7.5), + player -> Messenger.info(player, Component.text("Click here to go to the Arena GitHub repository") + .clickEvent(ClickEvent.openUrl("https://github.com/Minestom/Arena")))), + new NPC("Play", skins.get("Play"), instance, new Pos(-7.5, 15, -7.5), ArenaCommand::open) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/arena/utils/CommandUtils.java b/arena/utils/CommandUtils.java new file mode 100644 index 0000000..33b8601 --- /dev/null +++ b/arena/utils/CommandUtils.java @@ -0,0 +1,25 @@ +package net.minestom.arena.utils; + +import net.minestom.arena.lobby.Lobby; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.ConsoleSender; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; + +public final class CommandUtils { + public static boolean lobbyOnly(CommandSender sender, String commandString) { + if (!(sender instanceof Player player)) return false; + final Instance instance = player.getInstance(); + return instance == null || instance == Lobby.INSTANCE; + } + + public static boolean arenaOnly(CommandSender sender, String commandString) { + if (!(sender instanceof Player player)) return false; + final Instance instance = player.getInstance(); + return instance != null && instance != Lobby.INSTANCE; + } + + public static boolean consoleOnly(CommandSender sender, String commandString) { + return sender instanceof ConsoleSender; + } +} diff --git a/arena/utils/ConcurrentUtils.java b/arena/utils/ConcurrentUtils.java new file mode 100644 index 0000000..efeeb8c --- /dev/null +++ b/arena/utils/ConcurrentUtils.java @@ -0,0 +1,69 @@ +package net.minestom.arena.utils; + +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiPredicate; + +public final class ConcurrentUtils { + private ConcurrentUtils() { + //no instance + } + + /** + * Used to add timeout for CompletableFutures + * + * @param future the future which has to complete + * @param timeout duration to wait for the future to complete + * @param action Action to run after the future completes or the timeout is reached.
+ * Parameter means: + *
    + *
  • true - the timeout is reached
  • + *
  • false - future completed before timeout
  • + *
+ * @return the new CompletionStage + */ + public static CompletableFuture thenRunOrTimeout(CompletableFuture future, Duration timeout, BooleanConsumer action) { + final CompletableFuture f = new CompletableFuture<>(); + CompletableFuture.delayedExecutor(timeout.toNanos(), TimeUnit.NANOSECONDS).execute(() -> f.complete(true)); + future.thenRun(() -> f.complete(false)); + return f.thenAccept(action); + } + + /** + * Create a future from a CountDownLatch + * + * @return a future that completes when the countdown reaches zero + */ + public static CompletableFuture futureFromCountdown(CountDownLatch countDownLatch) { + final CompletableFuture future = new CompletableFuture<>(); + CompletableFuture.runAsync(() -> { + try { + countDownLatch.await(); + future.complete(null); + } catch (InterruptedException e) { + future.completeExceptionally(e); + } + }); + return future; + } + + public static boolean testAndSet(AtomicReference reference, BiPredicate predicate, V testValue, V newValue) { + for (;;) { + V prev = reference.get(); + if (predicate.test(prev, testValue)) { + if (reference.compareAndSet(prev, newValue)) return true; + } else { + return false; + } + } + } + + public static boolean testAndSet(AtomicReference reference, BiPredicate predicate, V newValue) { + return testAndSet(reference, predicate, newValue, newValue); + } +} diff --git a/arena/utils/FullbrightDimension.java b/arena/utils/FullbrightDimension.java new file mode 100644 index 0000000..cbc45c3 --- /dev/null +++ b/arena/utils/FullbrightDimension.java @@ -0,0 +1,15 @@ +package net.minestom.arena.utils; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.world.DimensionType; + +public class FullbrightDimension { + public static final DimensionType INSTANCE = DimensionType.builder(NamespaceID.from("minestom:full_bright")) + .ambientLight(2.0f) + .build(); + + static { + MinecraftServer.getDimensionTypeManager().addDimension(INSTANCE); + } +} diff --git a/arena/utils/ItemUtils.java b/arena/utils/ItemUtils.java new file mode 100644 index 0000000..3d4ddd0 --- /dev/null +++ b/arena/utils/ItemUtils.java @@ -0,0 +1,38 @@ +package net.minestom.arena.utils; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.item.ItemHideFlag; +import net.minestom.server.item.ItemMeta; +import net.minestom.server.item.ItemStack; +import org.jetbrains.annotations.Contract; + +public final class ItemUtils { + private ItemUtils() { + } + + @Contract("null -> null; !null -> !null") + public static Component stripItalics(Component component) { + if (component == null) return null; + + if (component.decoration(TextDecoration.ITALIC) == TextDecoration.State.NOT_SET) { + component = component.decoration(TextDecoration.ITALIC, false); + } + + return component; + } + + @Contract("null -> null; !null -> !null") + public static ItemStack stripItalics(ItemStack itemStack) { + if (itemStack == null) return null; + + return itemStack.withDisplayName(ItemUtils::stripItalics) + .withLore(lore -> lore.stream() + .map(ItemUtils::stripItalics) + .toList()); + } + + public static ItemMeta.Builder hideFlags(ItemMeta.Builder builder) { + return builder.hideFlag(ItemHideFlag.values()); + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7a41f35 --- /dev/null +++ b/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +group = 'me.zen' +version = '0.0.1' + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +dependencies { + implementation("com.github.Minestom:Minestom:aad7bdab0f") + implementation("de.articdive:jnoise-pipeline:4.0.0") + implementation("io.prometheus:simpleclient:0.16.0") + implementation("io.prometheus:simpleclient_hotspot:0.16.0") + implementation("io.prometheus:simpleclient_httpserver:0.16.0") + implementation("net.kyori:adventure-text-minimessage:4.12.0") +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..231a202 --- /dev/null +++ b/config.json @@ -0,0 +1,22 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 25565, + "mojangAuth": true, + "motd": [ + "Line1", + "Line2" + ] + }, + "proxy": { + "enabled": false, + "secret": "forwarding-secret" + }, + "permissions": { + "operators": [] + }, + "prometheus": { + "enabled": false, + "port": 9090 + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..caa829e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jan 24 07:27:22 PST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +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 + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem 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, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..89d3efe --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'MinestomServ' + diff --git a/src/main/java/me/zen/Initialization.java b/src/main/java/me/zen/Initialization.java new file mode 100644 index 0000000..fdccc95 --- /dev/null +++ b/src/main/java/me/zen/Initialization.java @@ -0,0 +1,175 @@ +package me.zen; + +import me.zen.instance.LobbyInstance; +import me.zen.util.MessageHelper; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.advancements.FrameType; +import net.minestom.server.advancements.notifications.Notification; +import net.minestom.server.advancements.notifications.NotificationCenter; +import net.minestom.server.adventure.audience.Audiences; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.ItemEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.item.PickupItemEvent; +import net.minestom.server.event.player.*; +import net.minestom.server.event.server.ServerListPingEvent; +import net.minestom.server.event.server.ServerTickMonitorEvent; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.monitoring.TickMonitor; +import net.minestom.server.ping.ResponseData; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.MathUtils; +import net.minestom.server.utils.identity.NamedAndIdentified; +import net.minestom.server.utils.time.TimeUnit; + +import java.time.Duration; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static net.minestom.server.MinecraftServer.LOGGER; + +public class Initialization { + private static final EventNode NODE = EventNode.all("node") + .addListener(AsyncPlayerConfigurationEvent.class, event -> { + final Player player = event.getPlayer(); + + // Check if there is any instance if not kick the player + if ( MinecraftServer.getInstanceManager().getInstances().isEmpty() ) { + player.kick(Component.text("No instances found", MessageHelper.RED_COLOR)); + return; + } + event.setSpawningInstance(LobbyInstance.INSTANCE); + player.setRespawnPoint(new Pos(0.5, 18, 0.5)); + + // Permission + player.setPermissionLevel(4); + }).addListener(PlayerSpawnEvent.class, event -> { + final Player player = event.getPlayer(); + + ItemStack itemStack = ItemStack.builder(Material.STONE) + .amount(64) + .build(); + player.getInventory().addItemStack(itemStack); + + if (event.isFirstSpawn()) { + Notification notification = new Notification( + Component.text("Welcome to the server!", MessageHelper.BLUE_COLOR), + FrameType.TASK, + Material.IRON_SWORD + ); + NotificationCenter.send(notification, event.getPlayer()); + + // Join message + Audiences.all().sendMessage(Component.text("[", MessageHelper.GRAY_COLOR) + .append(Component.text("+", MessageHelper.BLUE_COLOR)) + .append(Component.text("] ", MessageHelper.GRAY_COLOR)) + .append(Component.text(player.getUsername(), MessageHelper.BLUE_ISH_COLOR)) + .append(Component.text(" joined the server", MessageHelper.GRAY_COLOR)) + ); + } + }).addListener(PlayerDisconnectEvent.class, event -> { + final Player player = event.getPlayer(); + + // Leave message + Audiences.all().sendMessage(Component.text("[", MessageHelper.GRAY_COLOR) + .append(Component.text("-", MessageHelper.RED_ISH_COLOR)) + .append(Component.text("] ", MessageHelper.GRAY_COLOR)) + .append(Component.text(player.getUsername(), MessageHelper.RED_COLOR)) + .append(Component.text(" left the server", MessageHelper.GRAY_COLOR)) + ); + }).addListener(PlayerBlockInteractEvent.class, event -> { + var block = event.getBlock(); + var rawOpenProp = block.getProperty("open"); + if (rawOpenProp == null) return; + + block = block.withProperty("open", String.valueOf(!Boolean.parseBoolean(rawOpenProp))); + event.getInstance().setBlock(event.getBlockPosition(), block); + }).addListener(PickupItemEvent.class, event -> { + final Entity entity = event.getLivingEntity(); + + if (entity instanceof Player) { + // Cancel event if player does not have enough inventory space + final ItemStack itemStack = event.getItemEntity().getItemStack(); + event.setCancelled(!((Player) entity).getInventory().addItemStack(itemStack)); + } + }).addListener(ItemDropEvent.class, event -> { + final Player player = event.getPlayer(); + ItemStack droppedItem = event.getItemStack(); + + Pos playerPos = player.getPosition(); + ItemEntity itemEntity = new ItemEntity(droppedItem); + itemEntity.setPickupDelay(Duration.of(500, TimeUnit.MILLISECOND)); + itemEntity.setInstance(player.getInstance(), playerPos.withY(y -> y + 1.5)); + Vec velocity = playerPos.direction().mul(6); + itemEntity.setVelocity(velocity); + }); + + // This method is called from Main.java + public static void init() { + MinecraftServer.getInstanceManager().registerInstance(LobbyInstance.INSTANCE); + + var eventHandler = MinecraftServer.getGlobalEventHandler(); + eventHandler.addChild(NODE); + + // Monitor + AtomicReference lastTick = new AtomicReference<>(); + eventHandler.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(); + }); + + // Playerlist in server list + eventHandler.addListener(ServerListPingEvent.class, event -> { + ResponseData responseData = event.getResponseData(); + if (event.getConnection() != null) { + responseData.addEntry(NamedAndIdentified.named(Component.text("Hello! this is ZenZoya's Testing server.", MessageHelper.GRAY_COLOR))); + } + + responseData.setDescription(Component.text("Minestom Server", MessageHelper.BLUE_COLOR) + .append(Component.text(" - ", MessageHelper.GRAY_COLOR)) + .append(Component.text("Testing server", MessageHelper.BLUE_ISH_COLOR)) + ); + }); + + // Tablist header and footer + MinecraftServer.getSchedulerManager().scheduleTask(() -> { + Collection players = MinecraftServer.getConnectionManager().getOnlinePlayers(); + if (players.isEmpty()) return; + final Runtime runtime = Runtime.getRuntime(); + final TickMonitor tickMonitor = lastTick.get(); + final long ramUsage = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024; + + final Component header = Component.newline() + .append(Component.text("Vortres Network", MessageHelper.BLUE_COLOR)) + .append(Component.newline()) + .append(Component.text("Players: ", MessageHelper.GRAY_COLOR)).append(Component.text(players.size(), MessageHelper.BLUE_ISH_COLOR)) + .append(Component.newline()) + .append(Component.newline()) + .append(Component.text("RAM USAGE: ", MessageHelper.GRAY_COLOR).append(Component.text(ramUsage + "MB", MessageHelper.BLUE_ISH_COLOR)) + .append(Component.newline()) + .append(Component.text("TICK TIME: ", MessageHelper.GRAY_COLOR).append(Component.text(MathUtils.round(tickMonitor.getTickTime(), 2) + "ms", MessageHelper.BLUE_ISH_COLOR)))) + .append(Component.newline()); + + final Component footer = Component.newline() + .append(Component.text("Project: minestom.net", TextColor.color(0x8C8C8C)) + .append(Component.newline())); + + Audiences.players().sendPlayerListHeaderAndFooter(header, footer); + }, TaskSchedule.tick(2), TaskSchedule.tick(2)); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/Main.java b/src/main/java/me/zen/Main.java new file mode 100644 index 0000000..9dca46f --- /dev/null +++ b/src/main/java/me/zen/Main.java @@ -0,0 +1,82 @@ +package me.zen; + +import me.zen.block.TestBlockHandler; +import me.zen.block.placement.DripstonePlacementRule; +import me.zen.commands.*; +import me.zen.features.Features; +import me.zen.instance.LobbyInstance; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +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.Player; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.player.AsyncPlayerConfigurationEvent; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.event.GlobalEventHandler; +import net.minestom.server.event.server.ServerListPingEvent; +import net.minestom.server.event.server.ServerTickMonitorEvent; +import net.minestom.server.extras.lan.OpenToLAN; +import net.minestom.server.extras.lan.OpenToLANConfig; +import net.minestom.server.instance.block.BlockManager; +import net.minestom.server.monitoring.TickMonitor; +import net.minestom.server.ping.ResponseData; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.MathUtils; +import net.minestom.server.utils.identity.NamedAndIdentified; +import net.minestom.server.utils.time.TimeUnit; + +import java.time.Duration; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicReference; + +import static me.zen.config.ConfigHandler.CONFIG; +import static net.minestom.server.MinecraftServer.LOGGER; +import static net.minestom.server.MinecraftServer.getGlobalEventHandler; + +public class Main { + + public static void main(String[] args) { + // Server Init + MinecraftServer.setCompressionThreshold(128); + MinecraftServer minecraftServer = MinecraftServer.init(); + + MinecraftServer.getBenchmarkManager().enable(Duration.of(10, TimeUnit.SECOND)); + MinecraftServer.setBrandName("Vortres"); + if (CONFIG.prometheus().enabled()) Metrics.init(); + + // Events + BlockManager blockManager = MinecraftServer.getBlockManager(); + blockManager.registerBlockPlacementRule(new DripstonePlacementRule()); + blockManager.registerHandler(TestBlockHandler.INSTANCE.getNamespaceId(), () -> TestBlockHandler.INSTANCE); + + // Command Manager + CommandManager commandManager = MinecraftServer.getCommandManager(); + commandManager.register(new HealthCommand()); + commandManager.register(new ShutdownCommand()); + commandManager.register(new TeleportCommand()); + commandManager.register(new PlayersCommand()); + commandManager.register(new FindCommand()); + commandManager.register(new GiveCommand()); + commandManager.register(new SaveCommand()); + commandManager.register(new GamemodeCommand()); + commandManager.register(new SpawnCommand()); + commandManager.register(new SetBlockCommand()); + commandManager.register(new TestMessageHelper()); + commandManager.register(new SummonCommand()); + commandManager.register(new WorldCommand()); + + commandManager.setUnknownCommandCallback((sender, command) -> sender.sendMessage(Component.text("> Unknown command", TextColor.color(255, 50, 50)))); + + // Initialization + Initialization.init(); + + // Start Server + minecraftServer.start("0.0.0.0", 25565); + OpenToLAN.open(new OpenToLANConfig().eventCallDelay(Duration.of(1, TimeUnit.DAY))); + Runtime.getRuntime().addShutdownHook(new Thread(MinecraftServer::stopCleanly)); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/Metrics.java b/src/main/java/me/zen/Metrics.java new file mode 100644 index 0000000..ac81288 --- /dev/null +++ b/src/main/java/me/zen/Metrics.java @@ -0,0 +1,153 @@ +package me.zen; + +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 me.zen.config.ConfigHandler; +import me.zen.util.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.*; + +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(PlayerSpawnEvent.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); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/block/CampfireHandler.java b/src/main/java/me/zen/block/CampfireHandler.java new file mode 100644 index 0000000..cfbab2e --- /dev/null +++ b/src/main/java/me/zen/block/CampfireHandler.java @@ -0,0 +1,70 @@ +package me.zen.block; + +import net.minestom.server.instance.block.BlockHandler; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.tag.Tag; +import net.minestom.server.tag.TagReadable; +import net.minestom.server.tag.TagSerializer; +import net.minestom.server.tag.TagWritable; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jglrxavpok.hephaistos.nbt.NBT; +import org.jglrxavpok.hephaistos.nbt.NBTCompound; +import org.jglrxavpok.hephaistos.nbt.NBTList; +import org.jglrxavpok.hephaistos.nbt.NBTType; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class CampfireHandler implements BlockHandler { + + public static final Tag> ITEMS = Tag.View(new TagSerializer<>() { + private final Tag internal = Tag.NBT("Items"); + + @Override + public @Nullable List read(@NotNull TagReadable reader) { + NBTList item = (NBTList) reader.getTag(internal); + if (item == null) + return null; + List result = new ArrayList<>(); + item.forEach(nbtCompound -> { + int amount = nbtCompound.getAsByte("Count"); + String id = nbtCompound.getString("id"); + Material material = Material.fromNamespaceId(id); + result.add(ItemStack.of(material, amount)); + }); + return result; + } + + @Override + public void write(@NotNull TagWritable writer, @Nullable List value) { + if (value == null) { + writer.removeTag(internal); + return; + } + writer.setTag(internal, NBT.List( + NBTType.TAG_Compound, + value.stream() + .map(item -> NBT.Compound(nbt -> { + nbt.setByte("Count", (byte) item.amount()); + nbt.setByte("Slot", (byte) 1); + nbt.setString("id", item.material().name()); + })) + .toList() + )); + } + }); + + @Override + public @NotNull Collection> getBlockEntityTags() { + return List.of(ITEMS); + } + + @Override + public @NotNull NamespaceID getNamespaceId() { + return NamespaceID.from("minestom:test"); + } +} diff --git a/src/main/java/me/zen/block/TestBlockHandler.java b/src/main/java/me/zen/block/TestBlockHandler.java new file mode 100644 index 0000000..efc603a --- /dev/null +++ b/src/main/java/me/zen/block/TestBlockHandler.java @@ -0,0 +1,24 @@ +package me.zen.block; + +import net.minestom.server.instance.block.BlockHandler; +import net.minestom.server.utils.NamespaceID; +import org.jetbrains.annotations.NotNull; + +public class TestBlockHandler implements BlockHandler { + public static final BlockHandler INSTANCE = new TestBlockHandler(); + + @Override + public @NotNull NamespaceID getNamespaceId() { + return NamespaceID.from("minestom", "test"); + } + + @Override + public void onPlace(@NotNull Placement placement) { + System.out.println(placement); + } + + @Override + public void onDestroy(@NotNull Destroy destroy) { + System.out.println(destroy); + } +} diff --git a/src/main/java/me/zen/block/placement/DripstonePlacementRule.java b/src/main/java/me/zen/block/placement/DripstonePlacementRule.java new file mode 100644 index 0000000..5bf5a98 --- /dev/null +++ b/src/main/java/me/zen/block/placement/DripstonePlacementRule.java @@ -0,0 +1,76 @@ +package me.zen.block.placement; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.block.BlockFace; +import net.minestom.server.instance.block.rule.BlockPlacementRule; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Objects; + +public class DripstonePlacementRule extends BlockPlacementRule { + private static final String PROP_VERTICAL_DIRECTION = "vertical_direction"; // Tip, frustum, middle(0 or more), base + private static final String PROP_THICKNESS = "thickness"; + + public DripstonePlacementRule() { + super(Block.POINTED_DRIPSTONE); + } + + @Override + public @Nullable Block blockPlace(@NotNull PlacementState placementState) { + var blockFace = Objects.requireNonNullElse(placementState.blockFace(), BlockFace.TOP); + var direction = switch (blockFace) { + case TOP -> "up"; + case BOTTOM -> "down"; + default -> Objects.requireNonNullElse(placementState.cursorPosition(), Vec.ZERO).y() < 0.5 ? "up" : "down"; + }; + var thickness = getThickness(placementState.instance(), placementState.placePosition(), direction.equals("up")); + return block.withProperties(Map.of( + PROP_VERTICAL_DIRECTION, direction, + PROP_THICKNESS, thickness + )); + } + + @Override + public @NotNull Block blockUpdate(@NotNull UpdateState updateState) { + var direction = updateState.currentBlock().getProperty(PROP_VERTICAL_DIRECTION).equals("up"); + var newThickness = getThickness(updateState.instance(), updateState.blockPosition(), direction); + return updateState.currentBlock().withProperty(PROP_THICKNESS, newThickness); + } + + private @NotNull String getThickness(@NotNull Block.Getter instance, @NotNull Point blockPosition, boolean direction) { + var abovePosition = blockPosition.add(0, direction ? 1 : -1, 0); + var aboveBlock = instance.getBlock(abovePosition, Block.Getter.Condition.TYPE); + + // If there is no dripstone above, it is always a tip + if (aboveBlock.id() != Block.POINTED_DRIPSTONE.id()) + return "tip"; + // If there is an opposite facing dripstone above, it is always a merged tip + if ((direction ? "down" : "up").equals(aboveBlock.getProperty(PROP_VERTICAL_DIRECTION))) + return "tip_merge"; + + // If the dripstone above this is a tip, it is a frustum + var aboveThickness = aboveBlock.getProperty(PROP_THICKNESS); + if ("tip".equals(aboveThickness) || "tip_merge".equals(aboveThickness)) + return "frustum"; + + // At this point we know that there is a dripstone above, and that the dripstone is facing the same direction. + var belowPosition = blockPosition.add(0, direction ? -1 : 1, 0); + var belowBlock = instance.getBlock(belowPosition, Block.Getter.Condition.TYPE); + + // If there is no dripstone below, it is always a base + if (belowBlock.id() != Block.POINTED_DRIPSTONE.id()) + return "base"; + + // Otherwise it is a middle + return "middle"; + } + + @Override + public int maxUpdateDistance() { + return 2; + } +} diff --git a/src/main/java/me/zen/commands/DisplayCommand.java b/src/main/java/me/zen/commands/DisplayCommand.java new file mode 100644 index 0000000..1d478c1 --- /dev/null +++ b/src/main/java/me/zen/commands/DisplayCommand.java @@ -0,0 +1,100 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.metadata.display.AbstractDisplayMeta; +import net.minestom.server.entity.metadata.display.BlockDisplayMeta; +import net.minestom.server.entity.metadata.display.ItemDisplayMeta; +import net.minestom.server.entity.metadata.display.TextDisplayMeta; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +public class DisplayCommand extends Command { + + public DisplayCommand() { + super("display"); + + var follow = ArgumentType.Literal("follow"); + + addSyntax(this::spawnItem, ArgumentType.Literal("item")); + addSyntax(this::spawnBlock, ArgumentType.Literal("block")); + addSyntax(this::spawnText, ArgumentType.Literal("text")); + + addSyntax(this::spawnItem, ArgumentType.Literal("item"), follow); + addSyntax(this::spawnBlock, ArgumentType.Literal("block"), follow); + addSyntax(this::spawnText, ArgumentType.Literal("text"), follow); + } + + public void spawnItem(@NotNull CommandSender sender, @NotNull CommandContext context) { + if (!(sender instanceof Player player)) + return; + + var entity = new Entity(EntityType.ITEM_DISPLAY); + var meta = (ItemDisplayMeta) entity.getEntityMeta(); + meta.setTransformationInterpolationDuration(20); + meta.setItemStack(ItemStack.of(Material.STICK)); + entity.setInstance(player.getInstance(), player.getPosition()); + + if (context.has("follow")) { + startSmoothFollow(entity, player); + } + } + + public void spawnBlock(@NotNull CommandSender sender, @NotNull CommandContext context) { + if (!(sender instanceof Player player)) + return; + + var entity = new Entity(EntityType.BLOCK_DISPLAY); + var meta = (BlockDisplayMeta) entity.getEntityMeta(); + meta.setTransformationInterpolationDuration(20); + meta.setBlockState(Block.ORANGE_CANDLE_CAKE.stateId()); + entity.setInstance(player.getInstance(), player.getPosition()).join(); + + if (context.has("follow")) { + startSmoothFollow(entity, player); + } + } + + public void spawnText(@NotNull CommandSender sender, @NotNull CommandContext context) { + if (!(sender instanceof Player player)) + return; + + var entity = new Entity(EntityType.TEXT_DISPLAY); + var meta = (TextDisplayMeta) entity.getEntityMeta(); + meta.setTransformationInterpolationDuration(20); + meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER); + meta.setText(Component.text("Hello, world!")); + entity.setInstance(player.getInstance(), player.getPosition()); + + if (context.has("follow")) { + startSmoothFollow(entity, player); + } + } + + private void startSmoothFollow(@NotNull Entity entity, @NotNull Player player) { +// entity.setCustomName(Component.text("MY CUSTOM NAME")); +// entity.setCustomNameVisible(true); + MinecraftServer.getSchedulerManager().buildTask(() -> { + var meta = (AbstractDisplayMeta) entity.getEntityMeta(); + meta.setNotifyAboutChanges(true); + meta.setTransformationInterpolationStartDelta(1); + meta.setTransformationInterpolationDuration(20); +// meta.setPosRotInterpolationDuration(20); +// entity.teleport(player.getPosition()); +// meta.setScale(new Vec(5, 5, 5)); + meta.setTranslation(player.getPosition().sub(entity.getPosition())); + meta.setNotifyAboutChanges(true); + }).delay(20, TimeUnit.SERVER_TICK).repeat(20, TimeUnit.SERVER_TICK).schedule(); + } +} diff --git a/src/main/java/me/zen/commands/FindCommand.java b/src/main/java/me/zen/commands/FindCommand.java new file mode 100644 index 0000000..9dfab9a --- /dev/null +++ b/src/main/java/me/zen/commands/FindCommand.java @@ -0,0 +1,53 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; + +import java.util.Collection; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Float; +import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; + +public class FindCommand extends Command { + public FindCommand() { + super("find"); + + this.addSyntax( + this::executorEntity, + Literal("entity"), + Float("range") + ); + } + + private void executorEntity(CommandSender sender, CommandContext context) { + Player player = (Player) sender; + float range = context.get("range"); + + Collection entities = player.getInstance().getNearbyEntities(player.getPosition(), range); + + player.sendMessage(Component.text("Search result", TextColor.color(0x7575FF)) + .append(Component.text(":", TextColor.color(0x5F5F5F))) + ); + + for (Entity entity : entities) { + player.sendMessage(Component.text(" - ", TextColor.color(0x9595FF)) + .append(Component.text(String.valueOf(entity.getEntityType()), TextColor.color(0x8C8C8C))) + .append(Component.text(":", TextColor.color(0x5F5F5F))) + ); + player.sendMessage(Component.text(" - Meta: ", TextColor.color(0x5F5F5F)) + .append(Component.text(String.valueOf(" " + entity.getEntityMeta()), TextColor.color(0x8C8C8C))) + .append(Component.text("")) + ); + player.sendMessage(Component.text(" - Position: ", TextColor.color(0x5F5F5F)) + .append(Component.text(String.valueOf(" " + entity.getPosition()), TextColor.color(0x8C8C8C))) + .append(Component.text("")) + ); + player.sendMessage(""); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/commands/GamemodeCommand.java b/src/main/java/me/zen/commands/GamemodeCommand.java new file mode 100644 index 0000000..2e2b851 --- /dev/null +++ b/src/main/java/me/zen/commands/GamemodeCommand.java @@ -0,0 +1,131 @@ +package me.zen.commands; + +import net.kyori.adventure.audience.MessageType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentEnum; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.arguments.minecraft.ArgumentEntity; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.utils.entity.EntityFinder; + +import java.util.List; +import java.util.Locale; + +/** + * Command that make a player change gamemode, made in + * the style of the vanilla /gamemode command. + * + * @see ... + */ +public class GamemodeCommand extends Command { + + public GamemodeCommand() { + super("gamemode", "gm"); + + //GameMode parameter + ArgumentEnum gamemode = ArgumentType.Enum("gamemode", GameMode.class).setFormat(ArgumentEnum.Format.LOWER_CASED); + gamemode.setCallback((sender, exception) -> { + sender.sendMessage( + Component.text("Invalid gamemode ", NamedTextColor.RED) + .append(Component.text(exception.getInput(), NamedTextColor.WHITE)) + .append(Component.text("!")), MessageType.SYSTEM); + }); + + ArgumentEntity player = ArgumentType.Entity("targets").onlyPlayers(true); + + //Upon invalid usage, print the correct usage of the command to the sender + setDefaultExecutor((sender, context) -> { + String commandName = context.getCommandName(); + + sender.sendMessage(Component.text("Usage: /" + commandName + " [targets]", NamedTextColor.RED), MessageType.SYSTEM); + }); + + //Command Syntax for /gamemode + addSyntax((sender, context) -> { + //Limit execution to players only + if (!(sender instanceof Player p)) { + sender.sendMessage(Component.text("Please run this command in-game.", NamedTextColor.RED)); + return; + } + + //Check permission, this could be replaced with hasPermission + if (p.getPermissionLevel() < 2) { + sender.sendMessage(Component.text("You don't have permission to use this command.", NamedTextColor.RED)); + return; + } + + GameMode mode = context.get(gamemode); + + //Set the gamemode for the sender + executeSelf(p, mode); + }, gamemode); + + //Command Syntax for /gamemode [targets] + addSyntax((sender, context) -> { + //Check permission for players only + //This allows the console to use this syntax too + if (sender instanceof Player p && p.getPermissionLevel() < 2) { + sender.sendMessage(Component.text("You don't have permission to use this command.", NamedTextColor.RED)); + return; + } + + EntityFinder finder = context.get(player); + GameMode mode = context.get(gamemode); + + //Set the gamemode for the targets + executeOthers(sender, mode, finder.find(sender)); + }, gamemode, player); + } + + /** + * Sets the gamemode for the specified entities, and + * notifies them (and the sender) in the chat. + */ + private void executeOthers(CommandSender sender, GameMode mode, List entities) { + if (entities.size() == 0) { + //If there are no players that could be modified, display an error message + if (sender instanceof Player) + sender.sendMessage(Component.translatable("argument.entity.notfound.player", NamedTextColor.RED), MessageType.SYSTEM); + else sender.sendMessage(Component.text("No player was found", NamedTextColor.RED), MessageType.SYSTEM); + } else for (Entity entity : entities) { + if (entity instanceof Player p) { + if (p == sender) { + //If the player is the same as the sender, call + //executeSelf to display one message instead of two + executeSelf((Player) sender, mode); + } else { + p.setGameMode(mode); + + String gamemodeString = "gameMode." + mode.name().toLowerCase(Locale.ROOT); + Component gamemodeComponent = Component.translatable(gamemodeString); + Component playerName = p.getDisplayName() == null ? p.getName() : p.getDisplayName(); + + //Send a message to the changed player and the sender + p.sendMessage(Component.translatable("gameMode.changed", gamemodeComponent), MessageType.SYSTEM); + sender.sendMessage(Component.translatable("commands.gamemode.success.other", playerName, gamemodeComponent), MessageType.SYSTEM); + } + } + } + } + + /** + * Sets the gamemode for the executing Player, and + * notifies them in the chat. + */ + private void executeSelf(Player sender, GameMode mode) { + sender.setGameMode(mode); + + //The translation keys 'gameMode.survival', 'gameMode.creative', etc. + //correspond to the translated game mode names. + String gamemodeString = "gameMode." + mode.name().toLowerCase(Locale.ROOT); + Component gamemodeComponent = Component.translatable(gamemodeString); + + //Send the translated message to the player. + sender.sendMessage(Component.translatable("commands.gamemode.success.self", gamemodeComponent), MessageType.SYSTEM); + } +} diff --git a/src/main/java/me/zen/commands/GiveCommand.java b/src/main/java/me/zen/commands/GiveCommand.java new file mode 100644 index 0000000..1f9ac42 --- /dev/null +++ b/src/main/java/me/zen/commands/GiveCommand.java @@ -0,0 +1,57 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.command.builder.Command; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.PlayerInventory; +import net.minestom.server.inventory.TransactionOption; +import net.minestom.server.item.ItemStack; +import net.minestom.server.utils.entity.EntityFinder; + +import java.util.ArrayList; +import java.util.List; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Integer; +import static net.minestom.server.command.builder.arguments.ArgumentType.*; + +public class GiveCommand extends Command { + public GiveCommand() { + super("give"); + + setDefaultExecutor((sender, context) -> + sender.sendMessage(Component.text("Usage: /give []"))); + + addSyntax((sender, context) -> { + final EntityFinder entityFinder = context.get("target"); + int count = context.get("count"); + count = Math.min(count, PlayerInventory.INVENTORY_SIZE * 64); + ItemStack itemStack = context.get("item"); + + List itemStacks; + if (count <= 64) { + itemStack = itemStack.withAmount(count); + itemStacks = List.of(itemStack); + } else { + itemStacks = new ArrayList<>(); + while (count > 64) { + itemStacks.add(itemStack.withAmount(64)); + count -= 64; + } + itemStacks.add(itemStack.withAmount(count)); + } + + final List targets = entityFinder.find(sender); + for (Entity target : targets) { + if (target instanceof Player) { + Player player = (Player) target; + player.getInventory().addItemStacks(itemStacks, TransactionOption.ALL); + } + } + + sender.sendMessage(Component.text("Items have been given successfully!")); + + }, Entity("target").onlyPlayers(true), ItemStack("item"), Integer("count").setDefaultValue(() -> 1)); + + } +} diff --git a/src/main/java/me/zen/commands/HealthCommand.java b/src/main/java/me/zen/commands/HealthCommand.java new file mode 100644 index 0000000..8885f16 --- /dev/null +++ b/src/main/java/me/zen/commands/HealthCommand.java @@ -0,0 +1,76 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.arguments.number.ArgumentNumber; +import net.minestom.server.command.builder.condition.Conditions; +import net.minestom.server.command.builder.exception.ArgumentSyntaxException; +import net.minestom.server.entity.Player; + +public class HealthCommand extends Command { + + public HealthCommand() { + super("health"); + + setCondition(Conditions::playerOnly); + + setDefaultExecutor(this::defaultExecutor); + + var modeArg = ArgumentType.Word("mode").from("set", "add"); + + var valueArg = ArgumentType.Integer("value").between(0, 100); + + setArgumentCallback(this::onModeError, modeArg); + setArgumentCallback(this::onValueError, valueArg); + + addSyntax(this::sendSuggestionMessage, modeArg); + addSyntax(this::onHealthCommand, modeArg, valueArg); + } + + private void defaultExecutor(CommandSender sender, CommandContext context) { + sender.sendMessage(Component.text("Correct usage: health set|add ")); + } + + private void onModeError(CommandSender sender, ArgumentSyntaxException exception) { + sender.sendMessage(Component.text("SYNTAX ERROR: '" + exception.getInput() + "' should be replaced by 'set' or 'add'")); + } + + private void onValueError(CommandSender sender, ArgumentSyntaxException exception) { + final int error = exception.getErrorCode(); + final String input = exception.getInput(); + switch (error) { + case ArgumentNumber.NOT_NUMBER_ERROR: + sender.sendMessage(Component.text("SYNTAX ERROR: '" + input + "' isn't a number!")); + break; + case ArgumentNumber.TOO_LOW_ERROR: + case ArgumentNumber.TOO_HIGH_ERROR: + sender.sendMessage(Component.text("SYNTAX ERROR: " + input + " is not between 0 and 100")); + break; + } + } + + private void sendSuggestionMessage(CommandSender sender, CommandContext context) { + sender.sendMessage(Component.text("/health " + context.get("mode") + " [Integer]")); + } + + private void onHealthCommand(CommandSender sender, CommandContext context) { + final Player player = (Player) sender; + final String mode = context.get("mode"); + final int value = context.get("value"); + + switch (mode.toLowerCase()) { + case "set": + player.setHealth(value); + break; + case "add": + player.setHealth(player.getHealth() + value); + break; + } + + player.sendMessage(Component.text("You have now " + player.getHealth() + " health")); + } + +} \ No newline at end of file diff --git a/src/main/java/me/zen/commands/NotificationCommand.java b/src/main/java/me/zen/commands/NotificationCommand.java new file mode 100644 index 0000000..88f834f --- /dev/null +++ b/src/main/java/me/zen/commands/NotificationCommand.java @@ -0,0 +1,23 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.advancements.FrameType; +import net.minestom.server.advancements.notifications.Notification; +import net.minestom.server.advancements.notifications.NotificationCenter; +import net.minestom.server.command.builder.Command; +import net.minestom.server.entity.Player; +import net.minestom.server.item.Material; +import org.jetbrains.annotations.NotNull; + +public class NotificationCommand extends Command { + public NotificationCommand() { + super("notification"); + + setDefaultExecutor((sender, context) -> { + var player = (Player) sender; + + var notification = new Notification(Component.text("Hello World!"), FrameType.GOAL, Material.DIAMOND_AXE); + NotificationCenter.send(notification, player); + }); + } +} diff --git a/src/main/java/me/zen/commands/PlayersCommand.java b/src/main/java/me/zen/commands/PlayersCommand.java new file mode 100644 index 0000000..ac58177 --- /dev/null +++ b/src/main/java/me/zen/commands/PlayersCommand.java @@ -0,0 +1,35 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.entity.Player; +import net.minestom.server.network.ConnectionState; + +import java.util.Collection; +import java.util.List; + +public class PlayersCommand extends Command { + + public PlayersCommand() { + super("players"); + setDefaultExecutor(this::usage); + } + + private void usage(CommandSender sender, CommandContext context) { + final var players = List.copyOf(MinecraftServer.getConnectionManager().getOnlinePlayers()); + final int playerCount = players.size(); + sender.sendMessage(Component.text("Total players: " + playerCount)); + + final int limit = 15; + for (int i = 0; i < Math.min(limit, playerCount); i++) { + final var player = players.get(i); + sender.sendMessage(Component.text(player.getUsername())); + } + + if (playerCount > limit) sender.sendMessage(Component.text("...")); + } + +} diff --git a/src/main/java/me/zen/commands/RedirectTestCommand.java b/src/main/java/me/zen/commands/RedirectTestCommand.java new file mode 100644 index 0000000..4e363aa --- /dev/null +++ b/src/main/java/me/zen/commands/RedirectTestCommand.java @@ -0,0 +1,18 @@ +package me.zen.commands; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentLiteral; +import net.minestom.server.command.builder.arguments.ArgumentLoop; + +public class RedirectTestCommand extends Command { + public RedirectTestCommand() { + super("redirect"); + + final ArgumentLiteral a = new ArgumentLiteral("a"); + final ArgumentLiteral b = new ArgumentLiteral("b"); + final ArgumentLiteral c = new ArgumentLiteral("c"); + final ArgumentLiteral d = new ArgumentLiteral("d"); + + addSyntax(((sender, context) -> {}), new ArgumentLoop<>("test", a,b,c,d)); + } +} diff --git a/src/main/java/me/zen/commands/RemoveCommand.java b/src/main/java/me/zen/commands/RemoveCommand.java new file mode 100644 index 0000000..0b24969 --- /dev/null +++ b/src/main/java/me/zen/commands/RemoveCommand.java @@ -0,0 +1,34 @@ +package me.zen.commands; + +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.arguments.minecraft.ArgumentEntity; +import net.minestom.server.command.builder.condition.Conditions; +import net.minestom.server.entity.Entity; +import net.minestom.server.utils.entity.EntityFinder; + +public class RemoveCommand extends Command { + + public RemoveCommand() { + super("remove"); + addSubcommand(new RemoveEntities()); + } + + static class RemoveEntities extends Command { + private final ArgumentEntity entity; + + public RemoveEntities() { + super("entities"); + setCondition(Conditions::playerOnly); + entity = ArgumentType.Entity("entity"); + addSyntax(this::remove, entity); + } + + private void remove(CommandSender commandSender, CommandContext commandContext) { + final EntityFinder entityFinder = commandContext.get(entity); + entityFinder.find(commandSender).forEach(Entity::remove); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/commands/SaveCommand.java b/src/main/java/me/zen/commands/SaveCommand.java new file mode 100644 index 0000000..7572628 --- /dev/null +++ b/src/main/java/me/zen/commands/SaveCommand.java @@ -0,0 +1,43 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * A simple instance save command. + */ +public class SaveCommand extends Command { + + public SaveCommand() { + super("save"); + addSyntax(this::execute); + } + + private void execute(@NotNull CommandSender commandSender, @NotNull CommandContext commandContext) { + commandSender.sendMessage(Component.text("» ", TextColor.color(0x858585), TextDecoration.BOLD) + .append(Component.text("Saving instance...", TextColor.color(0x9595FF)))); + saveInstance(); + commandSender.sendMessage(Component.text("» ", TextColor.color(0x858585), TextDecoration.BOLD) + .append(Component.text("Instance saved!", TextColor.color(0x9595FF)))); + } + + static void saveInstance() { + for(var instance : MinecraftServer.getInstanceManager().getInstances()) { + CompletableFuture instanceSave = instance.saveChunksToStorage().thenCompose(v -> instance.saveChunksToStorage()); + try { + instanceSave.get(); + } catch (InterruptedException | ExecutionException e) { + MinecraftServer.getExceptionManager().handleException(e); + } + } + } +} diff --git a/src/main/java/me/zen/commands/SetBlockCommand.java b/src/main/java/me/zen/commands/SetBlockCommand.java new file mode 100644 index 0000000..39841f9 --- /dev/null +++ b/src/main/java/me/zen/commands/SetBlockCommand.java @@ -0,0 +1,30 @@ +package me.zen.commands; + +import me.zen.block.TestBlockHandler; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.minecraft.ArgumentBlockState; +import net.minestom.server.command.builder.arguments.relative.ArgumentRelativeBlockPosition; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.block.Block; + +import static net.minestom.server.command.builder.arguments.ArgumentType.BlockState; +import static net.minestom.server.command.builder.arguments.ArgumentType.RelativeBlockPosition; + +public class SetBlockCommand extends Command { + public SetBlockCommand() { + super("setblock"); + + final ArgumentRelativeBlockPosition position = RelativeBlockPosition("position"); + final ArgumentBlockState block = BlockState("block"); + + addSyntax((sender, context) -> { + final Player player = (Player) sender; + + Block blockToPlace = context.get(block); + if (blockToPlace.stateId() == Block.GOLD_BLOCK.stateId()) + blockToPlace = blockToPlace.withHandler(TestBlockHandler.INSTANCE); + + player.getInstance().setBlock(context.get(position).from(player), blockToPlace); + }, position, block); + } +} diff --git a/src/main/java/me/zen/commands/ShutdownCommand.java b/src/main/java/me/zen/commands/ShutdownCommand.java new file mode 100644 index 0000000..952500c --- /dev/null +++ b/src/main/java/me/zen/commands/ShutdownCommand.java @@ -0,0 +1,44 @@ +package me.zen.commands; + +import me.zen.util.MessageHelper; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.format.TextDecorationAndState; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * A simple shutdown command. + */ +public class ShutdownCommand extends Command { + + public ShutdownCommand() { + super("shutdown", "stop"); + addSyntax(this::execute); + } + + private void execute(@NotNull CommandSender commandSender, @NotNull CommandContext commandContext) { + // Save Instance + SaveCommand.saveInstance(); + + // Kick players + CompletableFuture.delayedExecutor(1, TimeUnit.SECONDS).execute(() -> { + for (Player player : MinecraftServer.getConnectionManager().getOnlinePlayers()) + player.kick(Component.text("Server is shutting down", MessageHelper.RED_COLOR)); + } + ); + + // Stop server + CompletableFuture.delayedExecutor(3, TimeUnit.SECONDS).execute(MinecraftServer::stopCleanly); + } +} diff --git a/src/main/java/me/zen/commands/SidebarCommand.java b/src/main/java/me/zen/commands/SidebarCommand.java new file mode 100644 index 0000000..7ac356e --- /dev/null +++ b/src/main/java/me/zen/commands/SidebarCommand.java @@ -0,0 +1,99 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.condition.Conditions; +import net.minestom.server.entity.Player; +import net.minestom.server.scoreboard.Sidebar; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SidebarCommand extends Command { + private final Sidebar sidebar = new Sidebar(Component.text("DEMO").decorate(TextDecoration.BOLD)); + private int currentLine = 0; + + public SidebarCommand() { + super("sidebar"); + + addLine("BLANK ", Sidebar.NumberFormat.blank()); + addLine("STYLE ", Sidebar.NumberFormat.styled(Component.empty().decorate(TextDecoration.STRIKETHROUGH).color(NamedTextColor.GRAY))); + addLine("FIXED ", Sidebar.NumberFormat.fixed(Component.text("FIXED").color(NamedTextColor.GRAY))); + addLine("NULL ", null); + + setDefaultExecutor((source, args) -> source.sendMessage(Component.text("Unknown syntax (note: title must be quoted)"))); + setCondition(Conditions::playerOnly); + + var option = ArgumentType.Word("option").from("add-line", "remove-line", "set-title", "toggle", "update-content", "update-score"); + var content = ArgumentType.String("content").setDefaultValue(""); + var targetLine = ArgumentType.Integer("target line").setDefaultValue(-1); + + addSyntax(this::handleSidebar, option); + addSyntax(this::handleSidebar, option, content); + addSyntax(this::handleSidebar, option, content, targetLine); + } + + + private void handleSidebar(CommandSender source, CommandContext context) { + Player player = (Player) source; + String option = context.get("option"); + String content = context.get("content"); + int targetLine = context.get("target line"); + if (targetLine == -1) targetLine = currentLine; + switch (option) { + case "add-line": + addLine(content, null); + break; + case "remove-line": + removeLine(); + break; + case "set-title": + setTitle(content); + break; + case "toggle": + toggleSidebar(player); + break; + case "update-content": + updateLineContent(content, String.valueOf(targetLine)); + break; + case "update-score": + updateLineScore(Integer.parseInt(content), String.valueOf(targetLine)); + break; + } + } + + private void addLine(@NotNull String content, @Nullable Sidebar.NumberFormat numberFormat) { + if (currentLine < 16) { + sidebar.createLine(new Sidebar.ScoreboardLine(String.valueOf(currentLine), Component.text(content).color(NamedTextColor.WHITE), currentLine, numberFormat)); + currentLine++; + } + } + + private void removeLine() { + if (currentLine > 0) { + sidebar.removeLine(String.valueOf(currentLine)); + currentLine--; + } + } + + private void setTitle(@NotNull String title) { + sidebar.setTitle(Component.text(title).decorate(TextDecoration.BOLD)); + } + + private void toggleSidebar(Player player) { + if (sidebar.getViewers().contains(player)) sidebar.removeViewer(player); + else sidebar.addViewer(player); + } + + private void updateLineContent(@NotNull String content, @NotNull String lineId) { + sidebar.updateLineContent(lineId, Component.text(content).color(NamedTextColor.WHITE)); + } + + private void updateLineScore(int score, @NotNull String lineId) { + sidebar.updateLineScore(lineId, score); + } +} diff --git a/src/main/java/me/zen/commands/SpawnCommand.java b/src/main/java/me/zen/commands/SpawnCommand.java new file mode 100644 index 0000000..f13e19e --- /dev/null +++ b/src/main/java/me/zen/commands/SpawnCommand.java @@ -0,0 +1,24 @@ +package me.zen.commands; + +import me.zen.instance.LobbyInstance; +import me.zen.util.MessageHelper; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; + +public class SpawnCommand extends Command { + public SpawnCommand() { + super("spawn"); + + setDefaultExecutor((sender, context) -> { + if (!(sender instanceof Player player)) return; + if (player.getInstance() == LobbyInstance.INSTANCE) { + player.teleport(new Pos(0.5, 16, 0.5)); + } else { + player.setInstance(LobbyInstance.INSTANCE); + player.teleport(new Pos(0.5, 16, 0.5)); + } + MessageHelper.fancyTitle(player, "Lobby"); + }); + } +} diff --git a/src/main/java/me/zen/commands/SummonCommand.java b/src/main/java/me/zen/commands/SummonCommand.java new file mode 100644 index 0000000..25ca556 --- /dev/null +++ b/src/main/java/me/zen/commands/SummonCommand.java @@ -0,0 +1,22 @@ +package me.zen.commands; + +import me.zen.entity.ZombieEntity; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.*; +import net.minestom.server.instance.Instance; + +public class SummonCommand extends Command { + public SummonCommand() { + super("summon"); + + setDefaultExecutor((sender, context) -> { + if (sender instanceof Player player) { + final Instance instance = player.getInstance(); + final Pos position = player.getPosition(); + Entity entity = new ZombieEntity(); + entity.setInstance(instance, position); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/commands/TeleportCommand.java b/src/main/java/me/zen/commands/TeleportCommand.java new file mode 100644 index 0000000..cc29b68 --- /dev/null +++ b/src/main/java/me/zen/commands/TeleportCommand.java @@ -0,0 +1,44 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.utils.location.RelativeVec; + +public class TeleportCommand extends Command { + + public TeleportCommand() { + super("tp"); + + setDefaultExecutor((source, context) -> source.sendMessage(Component.text("Usage: /tp x y z"))); + + var posArg = ArgumentType.RelativeVec3("pos"); + var playerArg = ArgumentType.Word("player"); + + addSyntax(this::onPlayerTeleport, playerArg); + addSyntax(this::onPositionTeleport, posArg); + } + + private void onPlayerTeleport(CommandSender sender, CommandContext context) { + final String playerName = context.get("player"); + Player pl = MinecraftServer.getConnectionManager().getOnlinePlayerByUsername(playerName); + if (sender instanceof Player player) { + player.teleport(pl.getPosition()); + } + sender.sendMessage(Component.text("Teleported to player " + playerName)); + } + + private void onPositionTeleport(CommandSender sender, CommandContext context) { + final Player player = (Player) sender; + + final RelativeVec relativeVec = context.get("pos"); + final Pos position = player.getPosition().withCoord(relativeVec.from(player)); + player.teleport(position); + player.sendMessage(Component.text("You have been teleported to " + position)); + } +} diff --git a/src/main/java/me/zen/commands/TestMessageHelper.java b/src/main/java/me/zen/commands/TestMessageHelper.java new file mode 100644 index 0000000..6019d04 --- /dev/null +++ b/src/main/java/me/zen/commands/TestMessageHelper.java @@ -0,0 +1,25 @@ +package me.zen.commands; + +import me.zen.util.MessageHelper; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import org.jetbrains.annotations.NotNull; + +public class TestMessageHelper extends Command { + public TestMessageHelper() { + super("testmsg"); + addSyntax(this::execute); + } + + private void execute(@NotNull CommandSender commandSender, @NotNull CommandContext commandContext) { + MessageHelper.info(commandSender, "Test message!"); + MessageHelper.warn(commandSender, "Test message!"); + MessageHelper.countdown(commandSender, 5).thenAccept(v -> { + MessageHelper.fancyTitle(commandSender, "Hello :>"); + }); + } +} diff --git a/src/main/java/me/zen/commands/TitleCommand.java b/src/main/java/me/zen/commands/TitleCommand.java new file mode 100644 index 0000000..c0e703e --- /dev/null +++ b/src/main/java/me/zen/commands/TitleCommand.java @@ -0,0 +1,29 @@ +package me.zen.commands; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.title.Title; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.condition.Conditions; +import net.minestom.server.entity.Player; + +public class TitleCommand extends Command { + public TitleCommand() { + super("title"); + setDefaultExecutor((source, args) -> source.sendMessage(Component.text("Unknown syntax (note: title must be quoted)"))); + setCondition(Conditions::playerOnly); + + var content = ArgumentType.String("content"); + + addSyntax(this::handleTitle, content); + } + + private void handleTitle(CommandSender source, CommandContext context) { + Player player = (Player) source; + String titleContent = context.get("content"); + + player.showTitle(Title.title(Component.text(titleContent), Component.empty(), Title.DEFAULT_TIMES)); + } +} diff --git a/src/main/java/me/zen/commands/WorldCommand.java b/src/main/java/me/zen/commands/WorldCommand.java new file mode 100644 index 0000000..f097852 --- /dev/null +++ b/src/main/java/me/zen/commands/WorldCommand.java @@ -0,0 +1,44 @@ +package me.zen.commands; + +import me.zen.instance.LobbyInstance; +import me.zen.instance.VanillaInstance; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.condition.Conditions; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; + +public class WorldCommand extends Command { + + public WorldCommand() { + super("world"); + setCondition(Conditions::playerOnly); + setDefaultExecutor((source, args) -> source.sendMessage("Usage: /world ")); + + var option = ArgumentType.Word("option").from("terrain", "flat", "void"); + + addSyntax(this::execute, option); + } + + private void execute(CommandSender source, CommandContext context) { + Player player = (Player) source; + String option = context.get("option"); + + switch (option) { + case "terrain": + player.setInstance(VanillaInstance.INSTANCE); + player.setRespawnPoint(new Pos(0.5, 70, 0.5)); + break; + case "flat": + player.setInstance(LobbyInstance.INSTANCE); + player.setRespawnPoint(new Pos(0.5, 70, 0.5)); + break; + case "void": + player.setInstance(LobbyInstance.INSTANCE); + player.setRespawnPoint(new Pos(0.5, 70, 0.5)); + break; + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/config/Config.java b/src/main/java/me/zen/config/Config.java new file mode 100644 index 0000000..a0ce21d --- /dev/null +++ b/src/main/java/me/zen/config/Config.java @@ -0,0 +1,24 @@ +package me.zen.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/src/main/java/me/zen/config/ConfigHandler.java b/src/main/java/me/zen/config/ConfigHandler.java new file mode 100644 index 0000000..e39aaa9 --- /dev/null +++ b/src/main/java/me/zen/config/ConfigHandler.java @@ -0,0 +1,174 @@ +package me.zen.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 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 args = new ArrayList<>(); + final List> argTypes = new ArrayList<>(); + for (RecordComponent component : recordComponents) { + args.add(argsMap.get(component.getName())); + argTypes.add(component.getType()); + } + + try { + Constructor constructor = clazz.getDeclaredConstructor(argTypes.toArray(Class[]::new)); + constructor.setAccessible(true); + return (T) constructor.newInstance(args.toArray(Object[]::new)); + } catch (ReflectiveOperationException e) { + return null; + } + } + } + + private Object instantiateWithDefaults(Class clazz) { + final List args = new ArrayList<>(); + final Constructor constructor = clazz.getDeclaredConstructors()[0]; + for (Parameter param : constructor.getParameters()) { + final Class paramClazz = param.getType(); + final Default def = param.getAnnotation(Default.class); + if (def == null) { + args.add(instantiateWithDefaults(paramClazz)); + continue; + } + try { + if (paramClazz == String.class) { + args.add(def.value()); + } else { + args.add(gson.getAdapter(TypeToken.get(param.getType())).fromJson(def.value())); + } + } catch (IOException ignored) { + args.add(null); + } + } + try { + return constructor.newInstance(args.toArray(Object[]::new)); + } catch (InstantiationException | InvocationTargetException | IllegalAccessException e) { + return null; + } + } + }; + } + } +} diff --git a/src/main/java/me/zen/config/ConfigurationReloadedEvent.java b/src/main/java/me/zen/config/ConfigurationReloadedEvent.java new file mode 100644 index 0000000..3ff01e6 --- /dev/null +++ b/src/main/java/me/zen/config/ConfigurationReloadedEvent.java @@ -0,0 +1,6 @@ +package me.zen.config; + +import net.minestom.server.event.Event; + +public record ConfigurationReloadedEvent(Config previousConfig, Config currentConfig) implements Event { +} diff --git a/src/main/java/me/zen/config/Default.java b/src/main/java/me/zen/config/Default.java new file mode 100644 index 0000000..d31e3c6 --- /dev/null +++ b/src/main/java/me/zen/config/Default.java @@ -0,0 +1,9 @@ +package me.zen.config; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Default { + String value(); +} diff --git a/src/main/java/me/zen/entity/ChickenEntity.java b/src/main/java/me/zen/entity/ChickenEntity.java new file mode 100644 index 0000000..1f3ed2c --- /dev/null +++ b/src/main/java/me/zen/entity/ChickenEntity.java @@ -0,0 +1,36 @@ +package me.zen.entity; + +import net.minestom.server.attribute.Attribute; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.ai.EntityAIGroupBuilder; +import net.minestom.server.entity.ai.goal.DoNothingGoal; +import net.minestom.server.entity.ai.goal.RandomLookAroundGoal; +import net.minestom.server.entity.ai.goal.RandomStrollGoal; +import net.minestom.server.entity.metadata.animal.ChickenMeta; + +import java.util.concurrent.ThreadLocalRandom; + +public class ChickenEntity extends EntityCreature { + + public ChickenEntity() { + super(EntityType.CHICKEN); + + addAIGroup( + new EntityAIGroupBuilder() + .addGoalSelector(new RandomLookAroundGoal(this, 4)) + .addGoalSelector(new RandomStrollGoal(this, 2)) + .addGoalSelector(new DoNothingGoal(this, 500, 0.1F)) + .build() + ); + + boolean isBaby = ThreadLocalRandom.current().nextBoolean(); + ((ChickenMeta) entityMeta).setBaby(isBaby); + if (isBaby) { + getAttribute(Attribute.MAX_HEALTH).setBaseValue(getMaxHealth() / 2); + heal(); + } + + getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/entity/ZombieEntity.java b/src/main/java/me/zen/entity/ZombieEntity.java new file mode 100644 index 0000000..61752fd --- /dev/null +++ b/src/main/java/me/zen/entity/ZombieEntity.java @@ -0,0 +1,44 @@ +package me.zen.entity; + +import net.minestom.server.attribute.Attribute; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.ai.EntityAIGroupBuilder; +import net.minestom.server.entity.ai.goal.DoNothingGoal; +import net.minestom.server.entity.ai.goal.MeleeAttackGoal; +import net.minestom.server.entity.ai.goal.RandomLookAroundGoal; +import net.minestom.server.entity.ai.goal.RandomStrollGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.ai.target.LastEntityDamagerTarget; +import net.minestom.server.entity.metadata.monster.zombie.ZombieMeta; +import net.minestom.server.utils.time.TimeUnit; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class ZombieEntity extends EntityCreature { + + public ZombieEntity() { + super(EntityType.ZOMBIE); + + addAIGroup( + new EntityAIGroupBuilder() + .addGoalSelector(new DoNothingGoal(this, 500, 0.1F)) + .addGoalSelector(new RandomLookAroundGoal(this, 4)) + .addGoalSelector(new MeleeAttackGoal(this, 500, 2, TimeUnit.SERVER_TICK)) + .addGoalSelector(new RandomStrollGoal(this, 2)) + .addTargetSelector(new LastEntityDamagerTarget(this, 15)) + .addTargetSelector(new ClosestEntityTarget(this, 15, LivingEntity.class)) + .build() + ); + + boolean isBaby = ThreadLocalRandom.current().nextBoolean(); + ((ZombieMeta) entityMeta).setBaby(isBaby); + if (isBaby) { + getAttribute(Attribute.MAX_HEALTH).setBaseValue(getMaxHealth() / 2); + heal(); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/features/BowFeature.java b/src/main/java/me/zen/features/BowFeature.java new file mode 100644 index 0000000..67876b3 --- /dev/null +++ b/src/main/java/me/zen/features/BowFeature.java @@ -0,0 +1,55 @@ +package me.zen.features; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventListener; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.item.ItemUpdateStateEvent; +import net.minestom.server.event.player.PlayerItemAnimationEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.item.Material; +import net.minestom.server.tag.Tag; +import net.minestom.server.utils.MathUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * @param projectileGenerator Uses the return value as the entity to shoot (in lambda arg 1 is shooter, arg 2 is power) + */ +record BowFeature(@NotNull BiFunction projectileGenerator) implements Feature { + private static final Tag CHARGE_SINCE_TAG = Tag.Long("bow_charge_since").defaultValue(Long.MAX_VALUE); + + @Override + public void hook(@NotNull EventNode node) { + node.addListener(EventListener.builder(PlayerItemAnimationEvent.class) + .handler(event -> event.getPlayer().setTag(CHARGE_SINCE_TAG, System.currentTimeMillis())) + .filter(event -> event.getItemAnimationType() == PlayerItemAnimationEvent.ItemAnimationType.BOW) + .build() + ).addListener(EventListener.builder(ItemUpdateStateEvent.class) + .handler(event -> { + final Player player = event.getPlayer(); + final double chargedFor = (System.currentTimeMillis() - player.getTag(CHARGE_SINCE_TAG)) / 1000D; + final double power = MathUtils.clamp((chargedFor * chargedFor + 2 * chargedFor) / 2D, 0, 1); + + if (power > 0.2) { + final EntityProjectile projectile = projectileGenerator.apply(player, power); + final Pos position = player.getPosition().add(0, player.getEyeHeight(), 0); + + projectile.setInstance(Objects.requireNonNull(player.getInstance()), position); + + Vec direction = projectile.getPosition().direction(); + projectile.shoot(position.add(direction).sub(0, 0.2, 0), power * 3, 1.0); + } + + // Restore arrow + player.getInventory().update(); + }) + .filter(event -> event.getItemStack().material() == Material.BOW) + .build()); + } +} diff --git a/src/main/java/me/zen/features/Feature.java b/src/main/java/me/zen/features/Feature.java new file mode 100644 index 0000000..6925f7b --- /dev/null +++ b/src/main/java/me/zen/features/Feature.java @@ -0,0 +1,10 @@ +package me.zen.features; + +import net.minestom.server.event.EventNode; +import net.minestom.server.event.trait.InstanceEvent; +import org.jetbrains.annotations.NotNull; + +@FunctionalInterface +public interface Feature { + void hook(@NotNull EventNode node); +} diff --git a/src/main/java/me/zen/features/Features.java b/src/main/java/me/zen/features/Features.java new file mode 100644 index 0000000..cd388b9 --- /dev/null +++ b/src/main/java/me/zen/features/Features.java @@ -0,0 +1,19 @@ +package me.zen.features; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityProjectile; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.function.*; + +public final class Features { + public static @NotNull Feature bow(BiFunction projectileGenerator) { + return new BowFeature(projectileGenerator); + } + + public static @NotNull Feature functionalItem(Predicate trigger, Consumer consumer, long cooldown) { + return new FunctionalItemFeature(trigger, consumer, cooldown); + } +} diff --git a/src/main/java/me/zen/features/FireballFeature.java b/src/main/java/me/zen/features/FireballFeature.java new file mode 100644 index 0000000..fa1bdf7 --- /dev/null +++ b/src/main/java/me/zen/features/FireballFeature.java @@ -0,0 +1,4 @@ +package me.zen.features; + +public class FireballFeature { +} diff --git a/src/main/java/me/zen/features/FunctionalItemFeature.java b/src/main/java/me/zen/features/FunctionalItemFeature.java new file mode 100644 index 0000000..40176b7 --- /dev/null +++ b/src/main/java/me/zen/features/FunctionalItemFeature.java @@ -0,0 +1,38 @@ +package me.zen.features; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventListener; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerHandAnimationEvent; +import net.minestom.server.event.trait.InstanceEvent; +import net.minestom.server.item.ItemStack; +import net.minestom.server.tag.Tag; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; + +record FunctionalItemFeature(Predicate trigger, Consumer consumer, long cooldown) implements Feature { + @Override + public void hook(@NotNull EventNode node) { + final UUID random = UUID.randomUUID(); + final Tag lastUseTag = Tag.Long("item_" + random + "_last_use").defaultValue(0L); + + node.addListener(EventListener.builder(PlayerHandAnimationEvent.class) + .handler(event -> { + final Player player = event.getPlayer(); + final long lastUse = player.getTag(lastUseTag); + final long now = System.currentTimeMillis(); + + if (now - lastUse >= cooldown) { + player.setTag(lastUseTag, now); + consumer.accept(player); + } + }) + .filter(event -> trigger.test(event.getPlayer().getItemInHand(event.getHand()))) + .filter(event -> event.getPlayer().getOpenInventory() == null) + .build() + ); + } +} diff --git a/src/main/java/me/zen/instance/FullbrightDimension.java b/src/main/java/me/zen/instance/FullbrightDimension.java new file mode 100644 index 0000000..2061771 --- /dev/null +++ b/src/main/java/me/zen/instance/FullbrightDimension.java @@ -0,0 +1,15 @@ +package me.zen.instance; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.world.DimensionType; + +public class FullbrightDimension { + public static final DimensionType INSTANCE = DimensionType.builder(NamespaceID.from("minestom:full_bright")) + .ambientLight(3.0f) + .build(); + + static { + MinecraftServer.getDimensionTypeManager().addDimension(INSTANCE); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/instance/LobbyInstance.java b/src/main/java/me/zen/instance/LobbyInstance.java new file mode 100644 index 0000000..0bca9b9 --- /dev/null +++ b/src/main/java/me/zen/instance/LobbyInstance.java @@ -0,0 +1,46 @@ +package me.zen.instance; + +import me.zen.util.MessageHelper; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.instance.AddEntityToInstanceEvent; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.player.PlayerEntityInteractEvent; +import net.minestom.server.instance.AnvilLoader; +import net.minestom.server.instance.Instance; + +import java.nio.file.Path; + +public final class LobbyInstance { + public static final Instance INSTANCE; + + static { + final Instance instance = MinecraftServer.getInstanceManager().createInstanceContainer( + FullbrightDimension.INSTANCE, new AnvilLoader(Path.of("Lobby")) + ); + + Map.create(instance, new Pos(2, 18, 9)); + instance.setTimeRate(0); + for (NPC npc : NPC.spawnNPCs(instance)) { + instance.eventNode().addListener(EntityAttackEvent.class, npc::handle) + .addListener(PlayerEntityInteractEvent.class, npc::handle); + } + + instance.eventNode().addListener(AddEntityToInstanceEvent.class, event -> { + if (!(event.getEntity() instanceof Player player)) return; + + if (player.getInstance() != null) player.scheduler().scheduleNextTick(() -> MessageHelper.fancyTitle(player, Component.text("Hello :>", TextColor.color(0x3D6DDB)))); + else onFirstSpawn(player); + }).addListener(ItemDropEvent.class, event -> event.setCancelled(true)); + + INSTANCE = instance; + } + + private static void onFirstSpawn(Player player) { + player.sendPackets(Map.packets()); + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/instance/LobbySidebarDisplay.java b/src/main/java/me/zen/instance/LobbySidebarDisplay.java new file mode 100644 index 0000000..4f7f8df --- /dev/null +++ b/src/main/java/me/zen/instance/LobbySidebarDisplay.java @@ -0,0 +1,26 @@ +//package me.zen.lobby; +// +//import net.kyori.adventure.text.Component; +//import net.kyori.adventure.text.format.NamedTextColor; +//import net.minestom.server.entity.Player; +//import net.minestom.server.scoreboard.Sidebar; +// +//public final class LobbySidebarDisplay { +// +// @Override +// protected Sidebar.ScoreboardLine createPlayerLine(Player player, Group group) { +// if (player.equals(group.leader())) { +// return new Sidebar.ScoreboardLine( +// player.getUuid().toString(), +// Component.text(Icons.STAR + " ").color(NamedTextColor.WHITE).append(player.getName().color(Messenger.ORANGE_COLOR)), +// 1 +// ); +// } else { +// return new Sidebar.ScoreboardLine( +// player.getUuid().toString(), +// player.getName(), +// 0 +// ); +// } +// } +//} diff --git a/src/main/java/me/zen/instance/Map.java b/src/main/java/me/zen/instance/Map.java new file mode 100644 index 0000000..b1b60e6 --- /dev/null +++ b/src/main/java/me/zen/instance/Map.java @@ -0,0 +1,82 @@ +package me.zen.instance; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.metadata.other.ItemFrameMeta; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.item.metadata.MapMeta; +import net.minestom.server.map.framebuffers.LargeGraphics2DFramebuffer; +import net.minestom.server.network.packet.server.SendablePacket; +import org.jetbrains.annotations.NotNull; + +import javax.imageio.ImageIO; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; + +final class Map { + private static SendablePacket[] packets = null; + + private Map() {} + + public static SendablePacket[] packets() { + if (packets != null) return packets; + + try { + final LargeGraphics2DFramebuffer framebuffer = new LargeGraphics2DFramebuffer(5 * 128, 3 * 128); + final InputStream imageStream = LobbyInstance.class.getResourceAsStream("/favicon.png"); + assert imageStream != null; + BufferedImage image = ImageIO.read(imageStream); + framebuffer.getRenderer().drawRenderedImage(image, AffineTransform.getScaleInstance(1.0, 1.0)); + packets = mapPackets(framebuffer); + + return packets; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Creates the maps on the board in the lobby + */ + public static void create(@NotNull Instance instance, Point maximum) { + final int maxX = maximum.blockX(); + final int maxY = maximum.blockY(); + final int z = maximum.blockZ(); + for (int i = 0; i < 15; i++) { + final int x = maxX - i % 5; + final int y = maxY - i / 5; + final int id = i; + + final Entity itemFrame = new Entity(EntityType.ITEM_FRAME); + final ItemFrameMeta meta = (ItemFrameMeta) itemFrame.getEntityMeta(); + itemFrame.setInstance(instance, new Pos(x, y, z, 180, 0)); + meta.setNotifyAboutChanges(false); + meta.setOrientation(ItemFrameMeta.Orientation.NORTH); + meta.setInvisible(true); + meta.setItem(ItemStack.builder(Material.FILLED_MAP) + .meta(MapMeta.class, builder -> builder.mapId(id)) + .build()); + meta.setNotifyAboutChanges(true); + } + } + + /** + * Creates packets for maps that will display an image on the board in the lobby + */ + private static SendablePacket[] mapPackets(@NotNull LargeGraphics2DFramebuffer framebuffer) { + final SendablePacket[] packets = new SendablePacket[15]; + for (int i = 0; i < 15; i++) { + final int x = i % 5; + final int y = i / 5; + packets[i] = framebuffer.createSubView(x * 128, y * 128).preparePacket(i); + } + + return packets; + } +} diff --git a/src/main/java/me/zen/instance/NPC.java b/src/main/java/me/zen/instance/NPC.java new file mode 100644 index 0000000..c5f5590 --- /dev/null +++ b/src/main/java/me/zen/instance/NPC.java @@ -0,0 +1,197 @@ +package me.zen.instance; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.*; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.metadata.PlayerMeta; +import net.minestom.server.entity.metadata.display.AbstractDisplayMeta; +import net.minestom.server.entity.metadata.display.TextDisplayMeta; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.player.PlayerEntityInteractEvent; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.SendablePacket; +import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket; +import net.minestom.server.network.packet.server.play.TeamsPacket; +import net.minestom.server.sound.SoundEvent; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.function.Consumer; + +// https://gist.github.com/iam4722202468/36630043ca89e786bb6318e296f822f8 +final class NPC extends EntityCreature { + private final String name; + private final PlayerSkin skin; + private final Consumer onClick; + + NPC(@NotNull Component name, @NotNull PlayerSkin skin, @NotNull Instance instance, + @NotNull Point spawn, @NotNull Consumer onClick) { + + super(EntityType.PLAYER); + this.name = ""; + this.skin = skin; + this.onClick = onClick; + + final PlayerMeta metaNPC = (PlayerMeta) getEntityMeta(); + metaNPC.setNotifyAboutChanges(false); + metaNPC.setCapeEnabled(false); + metaNPC.setJacketEnabled(true); + metaNPC.setCustomNameVisible(false); + metaNPC.setLeftSleeveEnabled(true); + metaNPC.setRightSleeveEnabled(true); + metaNPC.setLeftLegEnabled(true); + metaNPC.setRightLegEnabled(true); + metaNPC.setHatEnabled(true); + metaNPC.setNotifyAboutChanges(true); + + addAIGroup( + List.of(new LookAtPlayerGoal(this)), + List.of(new ClosestEntityTarget(this, 15, entity -> entity instanceof Player)) + ); + + setInstance(instance, spawn); + + // Spawn hologram above the npc + var entity = new Entity(EntityType.TEXT_DISPLAY); + var metaHolo = (TextDisplayMeta) entity.getEntityMeta(); + metaHolo.setTransformationInterpolationDuration(20); + metaHolo.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER); + metaHolo.setText(name); + entity.setNoGravity(true); + entity.setInstance(instance, spawn.add(0, 2.3, 0)); + } + + @NotNull + private TeamsPacket getCreateTeamPacket() { + String teamName = "npc-team-" + uuid.toString(); // Noting + + TeamsPacket.NameTagVisibility tag = TeamsPacket.NameTagVisibility.NEVER; + TeamsPacket.CollisionRule collision = TeamsPacket.CollisionRule.NEVER; + NamedTextColor color = NamedTextColor.WHITE; + + return new TeamsPacket(teamName, new TeamsPacket.CreateTeamAction( + Component.text(teamName), (byte) 1, tag, collision, color, Component.empty(), Component.empty(), List.of(getUuid().toString()) + )); + } + + public void handle(@NotNull EntityAttackEvent event) { + if (event.getTarget() != this) return; + if (!(event.getEntity() instanceof Player player)) return; + + player.playSound(Sound.sound() + .type(SoundEvent.BLOCK_NOTE_BLOCK_PLING) + .pitch(2) + .build(), event.getTarget()); + onClick.accept(player); + } + + public void handle(@NotNull PlayerEntityInteractEvent event) { + if (event.getTarget() != this) return; + if (event.getHand() != Player.Hand.MAIN) return; // Prevent duplicating event + + event.getEntity().playSound(Sound.sound() + .type(SoundEvent.BLOCK_NOTE_BLOCK_PLING) + .pitch(2) + .build(), event.getTarget()); + onClick.accept(event.getEntity()); + } + + @Override + public void updateNewViewer(@NotNull Player player) { + // Required to spawn player + final List properties = List.of( + new PlayerInfoUpdatePacket.Property("textures", skin.textures(), skin.signature()) + ); + + player.sendPacket(new PlayerInfoUpdatePacket(PlayerInfoUpdatePacket.Action.ADD_PLAYER, + new PlayerInfoUpdatePacket.Entry( + getUuid(), name, properties, false, 0, GameMode.SURVIVAL, null, + null) + ) + ); + + super.updateNewViewer(player); + } + + private static final class LookAtPlayerGoal extends GoalSelector { + private Entity target; + + public LookAtPlayerGoal(EntityCreature entityCreature) { + super(entityCreature); + } + + @Override + public boolean shouldStart() { + target = findTarget(); + return target != null; + } + + @Override + public void start() {} + + @Override + public void tick(long time) { + if (entityCreature.getDistanceSquared(target) > 225 || + entityCreature.getInstance() != target.getInstance()) { + target = null; + return; + } + + entityCreature.lookAt(target); + } + + @Override + public boolean shouldEnd() { + return target == null; + } + + @Override + public void end() {} + } + + public static List spawnNPCs(@NotNull Instance instance) { + try { + final java.util.Map skins = new HashMap<>(); + final Gson gson = new Gson(); + final JsonObject root = gson.fromJson(new String(LobbyInstance.class.getResourceAsStream("/skins.json") + .readAllBytes()), JsonObject.class); + + for (JsonElement skin : root.getAsJsonArray("skins")) { + final JsonObject object = skin.getAsJsonObject(); + final String owner = object.get("owner").getAsString(); + final String value = object.get("value").getAsString(); + final String signature = object.get("signature").getAsString(); + skins.put(owner, new PlayerSkin(value, signature)); + } + + return List.of( + new NPC(Component.text("Discord", TextColor.color(0x9595FF)), skins.get("Discord"), instance, new Pos(8.5, 15, 8.5), + player -> player.sendMessage(Component.text("Hello!") + .clickEvent(ClickEvent.openUrl("https://discord.gg/minestom")))) +// new NPC("Website", skins.get("Website"), instance, new Pos(-7.5, 15, 8.5), +// player -> Messenger.info(player, Component.text("Click here to go to the Minestom website") +// .clickEvent(ClickEvent.openUrl("https://minestom.net")))), +// new NPC("GitHub", skins.get("GitHub"), instance, new Pos(8.5, 15, -7.5), +// player -> Messenger.info(player, Component.text("Click here to go to the Arena GitHub repository") +// .clickEvent(ClickEvent.openUrl("https://github.com/Minestom/Arena")))), +// new NPC("Play", skins.get("Play"), instance, new Pos(-7.5, 15, -7.5), ArenaCommand::open) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/me/zen/instance/VanillaInstance.java b/src/main/java/me/zen/instance/VanillaInstance.java new file mode 100644 index 0000000..41a66ef --- /dev/null +++ b/src/main/java/me/zen/instance/VanillaInstance.java @@ -0,0 +1,67 @@ +package me.zen.instance; + +import de.articdive.jnoise.generators.noisegen.opensimplex.FastSimplexNoiseGenerator; +import de.articdive.jnoise.generators.noisegen.perlin.PerlinNoiseGenerator; +import de.articdive.jnoise.modules.octavation.OctavationModule; +import de.articdive.jnoise.pipeline.JNoise; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.AnvilLoader; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.instance.InstanceManager; +import net.minestom.server.instance.block.Block; +import net.minestom.server.tag.Tag; +import net.minestom.server.utils.MathUtils; +import net.minestom.server.utils.NamespaceID; +import net.minestom.server.world.DimensionType; + +import java.awt.*; +import java.util.concurrent.ThreadLocalRandom; + +import static kotlin.reflect.jvm.internal.impl.builtins.StandardNames.FqNames.unit; + +public class VanillaInstance { + + public static final Instance INSTANCE; + + static { + InstanceManager instanceManager = MinecraftServer.getInstanceManager(); + + DimensionType custom_dimension1 = DimensionType.builder(NamespaceID.from("vortres:custom_dimension1")) + .ambientLight(1.0f) + .build(); + + MinecraftServer.getDimensionTypeManager().addDimension(custom_dimension1); + + InstanceContainer instance = instanceManager.createInstanceContainer(custom_dimension1, new AnvilLoader("worlds/world_normal")); + + // Noise used for the terrain with 3D noise + JNoise noise = JNoise.newBuilder() + .fastSimplex(FastSimplexNoiseGenerator.newBuilder().build()) + .scale(0.03289) //0.0025 (very smooth) + .octavation(OctavationModule.newBuilder().setOctaves(4).setNoiseSource(PerlinNoiseGenerator.newBuilder().build()).build()) + .build(); + + instance.setGenerator(unit -> { + final Point start = unit.absoluteStart(); + for (int x = 0; x < unit.size().x(); x++) { + for (int z = 0; z < unit.size().z(); z++) { + Point bottom = start.add(x, 0, z); + + final double modifier = MathUtils.clamp((bottom.distance(Pos.ZERO.withY(bottom.y())) - 73) / 50, 0, 2); + double y = noise.evaluateNoise(bottom.x(), bottom.z()) * modifier; + y = (y > 0 ? y * 4 : y * 7) + 64; + unit.modifier().fill(bottom, bottom.add(1, 0, 1).withY(y), Block.STONE); + } + } + }); + + + instance.enableAutoChunkLoad(true); + instanceManager.registerInstance(instance); + + INSTANCE = instance; + } +} diff --git a/src/main/java/me/zen/util/MessageHelper.java b/src/main/java/me/zen/util/MessageHelper.java new file mode 100644 index 0000000..c9cebc5 --- /dev/null +++ b/src/main/java/me/zen/util/MessageHelper.java @@ -0,0 +1,73 @@ +package me.zen.util; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +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; +import java.util.function.Consumer; + +public final class MessageHelper { + public static final TextColor BLUE_COLOR = TextColor.color(0x3D6DDB); + public static final TextColor BLUE_ISH_COLOR = TextColor.color(0x8C8CFF); + public static final TextColor GRAY_COLOR = TextColor.color(0xA5A5A5); + public static final TextColor RED_COLOR = TextColor.color(0xE6143C); + public static final TextColor RED_ISH_COLOR = TextColor.color(0xFF8C8C); + + 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("> ").color(TextColor.color(0x3D6DDB)).decoration(TextDecoration.BOLD, true) + .append(message.color(GRAY_COLOR)) + ); + } + + 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("! ", RED_COLOR, TextDecoration.BOLD) + .append(message.color(NamedTextColor.GRAY))); + } + + public static void fancyTitle(Audience audience, String message) { + fancyTitle(audience, Component.text(message)); + } + + public static void fancyTitle(Audience audience, Component message) { + audience.showTitle(Title.title(message, Component.empty())); + audience.playSound(Sound.sound(SoundEvent.BLOCK_NOTE_BLOCK_PLING, Sound.Source.BLOCK, 1, 1), Sound.Emitter.self()); + } + + 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; + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/util/NetworkUsage.java b/src/main/java/me/zen/util/NetworkUsage.java new file mode 100644 index 0000000..e766911 --- /dev/null +++ b/src/main/java/me/zen/util/NetworkUsage.java @@ -0,0 +1,62 @@ +package me.zen.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URISyntaxException; + +public final class NetworkUsage { + private static final File baseDir = new File("./util/net"); + private static final Logger LOGGER = LoggerFactory.getLogger(NetworkUsage.class); + public static final String UTIL_BYTES_IN = new File(baseDir, "bytes-in").toString(); + private static final String UTIL_BYTES_OUT = new File(baseDir, "bytes-out").toString(); + public static final String UTIL_BYTES_RESET = new File(baseDir, "bytes-reset").toString(); + + public static long getBytesSent() { + return Long.parseLong(execute(UTIL_BYTES_OUT)); + } + + public static long getBytesReceived() { + return Long.parseLong(execute(UTIL_BYTES_IN)); + } + + public static void resetCounters() { + execute(UTIL_BYTES_RESET); + } + + public static boolean checkEnabledOrExtract() { + if (baseDir.isDirectory()) { + if (new File(baseDir, "enabled").exists()) { + return true; + } else { + LOGGER.warn("Network utils aren't enabled, metrics for network I/O will not be reported!"); + return false; + } + } else { + LOGGER.warn("Extracting network utils, refer to the README.md in {} to enable network metrics.", baseDir); + try { + ResourceUtils.extractResource("util/net"); + } catch (URISyntaxException | IOException e) { + LOGGER.error("Failed to extract utils", e); + } + return false; + } + } + + private static String execute(String command) { + try { + final String line = new BufferedReader(new InputStreamReader(Runtime.getRuntime().exec(command).getInputStream())).readLine(); + if (line == null || line.length() < 2 && !line.equals("0")) { + LOGGER.error("Command returned without value! Did you persist iptables?"); + return "0"; + } + return line; + } catch (IOException e) { + return "0"; + } + } +} \ No newline at end of file diff --git a/src/main/java/me/zen/util/ResourceUtils.java b/src/main/java/me/zen/util/ResourceUtils.java new file mode 100644 index 0000000..bca2d17 --- /dev/null +++ b/src/main/java/me/zen/util/ResourceUtils.java @@ -0,0 +1,57 @@ +package me.zen.util; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.Map; +import java.util.stream.Stream; + +public final class ResourceUtils { + public static void extractResource(String source) throws URISyntaxException, IOException { + final URI uri = ResourceUtils.class.getResource("/" + source).toURI(); + FileSystem fileSystem = null; + + // Only create a new filesystem if it's a jar file + // (People can run this from their IDE too) + if (uri.toString().startsWith("jar:")) + fileSystem = FileSystems.newFileSystem(uri, Map.of("create", "true")); + + try { + final Path jarPath = Paths.get(uri); + final Path target = Path.of(source); + if (Files.exists(target)) { + try (Stream pathStream = Files.walk(target)) { + pathStream.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + Files.walkFileTree(jarPath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException { + Path currentTarget = target.resolve(jarPath.relativize(dir).toString()); + Files.createDirectories(currentTarget); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + final Path to = target.resolve(jarPath.relativize(file).toString()); + Files.copy(file, to, StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } finally { + if (fileSystem != null) + fileSystem.close(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/favicon.png b/src/main/resources/favicon.png new file mode 100644 index 0000000..db53096 Binary files /dev/null and b/src/main/resources/favicon.png differ diff --git a/src/main/resources/minestom.png b/src/main/resources/minestom.png new file mode 100644 index 0000000..c281f30 Binary files /dev/null and b/src/main/resources/minestom.png differ diff --git a/src/main/resources/skins.json b/src/main/resources/skins.json new file mode 100644 index 0000000..a2ce652 --- /dev/null +++ b/src/main/resources/skins.json @@ -0,0 +1,24 @@ +{ + "skins": [ + { + "owner": "Discord", + "value": "ewogICJ0aW1lc3RhbXAiIDogMTY0MjEwMzIzOTQ5MiwKICAicHJvZmlsZUlkIiA6ICI0N2RhZjU4YmJkNTM0OWU2YTI1MWYzMDZkYzhlYjc2ZiIsCiAgInByb2ZpbGVOYW1lIiA6ICJZeXJvIiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2E0MGViN2VkMzc5ZjY1YWRmYmIyYjIxMTU5YjYxYjdhZDhkNDU5MTU2NTA3YjdhYmQyMDkwMGY4MTg2NmZhM2UiLAogICAgICAibWV0YWRhdGEiIDogewogICAgICAgICJtb2RlbCIgOiAic2xpbSIKICAgICAgfQogICAgfQogIH0KfQ==", + "signature": "lZbPVDAeCMARgk529QO8KOiap305UJJG3ea0DCdBh09RhlYJ4eT4Sr5iAM2P9HKYuXVN3oZ+x2tAsDb5IZ6d/YP07U1FMD+5uGuSgYY7ifo8tDmVgb0OR7NUUi/Ztjre5lpRx6mVQEiRyOvMbSDUVUyZtXG+LjWCAJn81wJ3RVyJEdhJOeNuTbINam26Xi+OMD4YMqmgouHiH2g7DSo2VIM+Gp6TnRU+9i9qu+wdVZb7JINbIw/VWrKU3ysUOoy1+6oGzjLEQu+cR891hb+JVQGD8pE8ugwes19T4LAzHJbxYrMvAYe9qfkPXHdD33NMjMbqy/xVKu5CPJij1SCYWJhVwahGkxAG4xiil5f87pfjhUP6mNdZkKj9O+8ND3eron0co+nteHru0uOTgw/xI25Uno58A4NqANhj5vb0cmT0Mh6NKW+kNQvfqr0LQxy4NUcno5Qg56iHp195wY6XZmYtlNsllwvP69H1fJDkGaPP8WdNOn6ewc1SkePHO46GhVgNexIHbrywVZiXMRLJLamos1znRBJOZEGfc+kTf1SVBq51UAJ7yzn2j6iM/fexeAGF15W7DSEJXQEoR0zT2qSU5A3LUms4IrN9F5RmSm81rpjNCujeC0JlcgCf/KLBLvn0MzORS2etHQv51yQmvD4gRWBEBTZEpFBcirWAS8c=" + }, + { + "owner": "Website", + "value" : "ewogICJ0aW1lc3RhbXAiIDogMTY3MjE3MTU1MjU1OCwKICAicHJvZmlsZUlkIiA6ICJiNTAwOGEyMGJkY2U0YjJlOTU5NWZlNzY2MDlmYjUzNiIsCiAgInByb2ZpbGVOYW1lIiA6ICJNT1JJU3hTVE9QQkFOIiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2RmZDIwOWU5ZTk2MGY4MDQ5ZDdlYWY0NTJmYzg1YmYwYmVjZjliMTUwYjQ2MzIwMTY2Mjg1YzdhYjEzMjMxN2UiCiAgICB9CiAgfQp9", + "signature" : "yWIH6gVDRhgd/HwVYpRT87NBHf+q9EgYs/CYqIwnz5xXR22k29c7MTnaYFvWukbwF1FhWNilAyYzagSn/7ScvOpYn0RtyTWk656cwjzFSbTsSFzRwUl5f4mu1EeunYh8v5cZH27KTfAI7a9Q7ylXz7NoAbvaw056thXa7jHhLhFdPECnziRTnv9jDRwpoN/4jblmdOz5NCLtynubf8hwIwm9od18tXy4+gsV3aXS5+1MirpWDizqdozb9mtwzML9NYwVNpO2bRB9KYJ91VUWqxjfTy/q0xFQ1paolq4pp3tgvLXw0y+rdwCsCgh39JA4MKvIIJShww5xbqo4oFBRDj+/BI3+Y154Ess1004vE+iTRdt+az0v4y+evnOQLgryEr/36QzZOndpSFqYfKPl1MeUeZe1u4VDQJcgyJImg2TZJbG2WOmmTySWSEPrHcYC6c3Y9rVnQ6Zi4NxTe4e6/ZuDQVm14fuSUPd4Ll7/aIDyumHupBMMbBEa9qCmuZJPT5iWVlIGfzA2Dg/kea4Jw9WudUmiCYngB56HZEivDPniIxeGSTRFHMR2FfTKnLkxb2LDOvD+CgDWyr8cGy4xnB2hwdY2n28cCAYI5axj0qzCpHMl8Y90e2rKfX7NsUvyivbAVRwAsd/bWkkJoZ4/QStFEjV//81iNbuUz/4lFQA=" + }, + { + "owner": "GitHub", + "value": "ewogICJ0aW1lc3RhbXAiIDogMTU5MTYwOTI1MDUzMCwKICAicHJvZmlsZUlkIiA6ICIyM2YxYTU5ZjQ2OWI0M2RkYmRiNTM3YmZlYzEwNDcxZiIsCiAgInByb2ZpbGVOYW1lIiA6ICIyODA3IiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2JkZDE2M2ExMDI4MmNmMWUyZWFjNjI0MGZkMWI4MzZiMzk2YjBkNzNhYmUxZjUyZTcxODlkY2RhNTYwMzU4NWIiCiAgICB9CiAgfQp9", + "signature": "ZnObAICBQgfqIqgTZXSG5Si2nmkAridypbLD1vmsFlbkXDti7f0wcl5kmHAyekZHWBiVCkcPlIhQVc/6/GHeSHZ1j5W+SBvOMIhAc9TlEMsxDP5ni3DGV4xT3QpaS/WrvVB8xCT2D56T5q2OXJUkn/+jQwpwhtNyyyM4xIr28i3XBm0QxCVkP0aU414jLcWe31Uypsh7N8ky/MRQWLccKUlrwuUzxWekXHTlU9MNvWshDELd7o4bZFGi3EvD/80XB3C+57NJjnyg9HOnUazxdOc9QsPW66XlEDOPLVxCUWEukGIIAj3lIaHBHkDHm52oNXzTvKVQaSYQooEoiuzibSG7E8vD1oDKmjzsSAvfo/xJAxjunDVJ1xcMNKkugcGxNeTetWpiXycsx0pGfhsXIz7IdaPBWbo8T9iHlidpyBvRUM+4YvDJmuCxqTdDi1L0uF3rXd0tsgoHICfBiyXi0098BcpbQ4FfLOSR+RgaAU6aXDrLHaedFFJMzwweF2KSUc8KW8naZbYKbKK7zSljJF46/Y5Be1ciymTMaMQHG6yquUXX2pcNS3INNWu3lX4HiS7tt9WULqCv+yvZ4gpSeB38JjvpnvXtuxBK6P2a+73SaK0VSU2RvIFA4b9lo7G6+DIqBs8FDUjtxZW4kSc2h3gFp7fsviO7hcrnsxx+bqg=" + }, + { + "owner" : "Play", + "value" : "ewogICJ0aW1lc3RhbXAiIDogMTYxNDExMjY4NDYwNiwKICAicHJvZmlsZUlkIiA6ICI0ZjU2ZTg2ODk2OGU0ZWEwYmNjM2M2NzRlNzQ3ODdjOCIsCiAgInByb2ZpbGVOYW1lIiA6ICJDVUNGTDE1IiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlLzRkYjRjMTUyZWRmMWQ3Y2YxZGVmNjhjZmFmNGE3M2RiM2U3MWZhYTA0ZDg2MzUxOTIzNWYyZjQ3NWNhOTc3YjEiLAogICAgICAibWV0YWRhdGEiIDogewogICAgICAgICJtb2RlbCIgOiAic2xpbSIKICAgICAgfQogICAgfQogIH0KfQ==", + "signature" : "VOhJBSZzUgEDxhV7Qbvi7bU7Bw3FGTx2/g39GQ1H3XOn4T3QmgaifdqeU1TBcwO5VzD0bi+XB498sGokDNJBJyMVCmDlFz3z4iD2L8GUXtJJcl26H/JT6aiOa1APgWoQvlHJfsMh6W24dsUXs+FbsQ+lNw3NmBTpgID9VX/2s6p8cdg+ASxkVQLTPRUEFOPiaVCGoR0wtnUUNAcUUFIq2Y6P5sWO5bnD2FcW93wMg3IH4z3RwqXhr+fLVdZH28w6gtJQVDwGQ4Ot5SjpNva201TPbKbjAoeC9KnOpNpt8djlYB96y3LR/MHI4mRAEQzIf3PZLxc8NZR3BzFloCsmyxRmMcJV3Fdcd3rjHfKd9LGmnaaBluPTuXx6FRW9M5yy9aGsZ2oXz7s9aLRdTz1Xp3vQDA+auWSR5ZWIxVHPNAk/5O3avZ838QnL2LI8V3FFYZquoaXPpxlZPEWHV5dT8SOgPjAaq1S8jR+fugbawnNHk4hLR47DJuywO4UAKFhDfLSKPK0FSd1JWhGm+AaoLyxSGv85RWS4L0y5Az7ooXycskrvGoBlVUbeVYrPh4+RrUDIMVhj/NUQ1FDD5SFKxWKiO4+BIv9DjUrxiWwvYfSrE8vO+bZpMvlp5vUgp/zTaHppRdO6FEf2el62Y0Wvvfx8aL90EbvACeSKResFqIk=" + } + ] +} diff --git a/src/main/resources/util/net/README.md b/src/main/resources/util/net/README.md new file mode 100644 index 0000000..1ca830b --- /dev/null +++ b/src/main/resources/util/net/README.md @@ -0,0 +1,61 @@ +# Network Usage Monitoring Utils +This directory contains three executables to provide network usage metrics. +You can replace these binaries with your own solution, for that see the implementation details. + +## Using the bundled utils +### Prerequisites +You must have root privileges, iptables and grep installed! +### Setup +If you don't care about the commands you run as root, then for your convenience we provide +you with a `setup.sh`, all you have to do is run it as **root**, and you are ready to go, +otherwise follow the next steps, so you know what you are doing. + +**1. Step** +Create chains +```shell +iptables -N MCSRV_OUT +iptables -A OUTPUT -j MCSRV_OUT +iptables -N MCSRV_IN +iptables -A INPUT -j MCSRV_IN +``` + +**2. Step** +Add rules. Replace `` with the actual port! +```shell +iptables -A MCSRV_OUT -p tcp --sport +iptables -A MCSRV_IN -p tcp --dport +``` + +**3. Step** +Change file owners, set setuid bit +```shell +chown root: bytes-out +chown root: bytes-in +chown root: bytes-reset +chmod u+s bytes-out +chmod u+s bytes-in +chmod u+s bytes-reset +``` + +**4. Step** +Create a file named `enabled` to enable network metrics. + +## Implementation details +If you choose to replace the bundled utils make sure they return **byte count** without +any prefix or suffix. +### `bytes-out` and `bytes-in` +These are compiled from their respective c source files, internally they use the following +command to retrieve the total bytes: +```shell +iptables -L -nvx | tail -1 | grep -Po '^\s*[0-9]+\s+\K([0-9]+)' +``` +it was necessary to wrap this in a binary executable so the server process doesn't +have to be run as root. + +### `bytes-reset` +This is used to reset the counters, internally it executes the following commands: +```shell +iptables -L -Z MCSRV_OUT -v +iptables -L -Z MCSRV_IN -v +``` +It's wrapped in binary executable for the same reason as above diff --git a/src/main/resources/util/net/bytes-in b/src/main/resources/util/net/bytes-in new file mode 100644 index 0000000..69ab7b1 Binary files /dev/null and b/src/main/resources/util/net/bytes-in differ diff --git a/src/main/resources/util/net/bytes-in.c b/src/main/resources/util/net/bytes-in.c new file mode 100644 index 0000000..921778c --- /dev/null +++ b/src/main/resources/util/net/bytes-in.c @@ -0,0 +1,26 @@ +#include +#include + +#define BUFSIZE 128 + +int main() { + setuid(0); + char *cmd = "iptables -L MCSRV_IN -nvx | tail -1 | grep -Po '^\\s*[0-9]+\\s+\\K([0-9]+)'"; + + char buf[BUFSIZE]; + FILE *fp; + + setuid(0); + if ((fp = popen(cmd, "r")) == NULL) { + return -1; + } + + fgets(buf, BUFSIZE, fp); + printf(buf); + + if (pclose(fp)) { + return -1; + } + + return 0; +} diff --git a/src/main/resources/util/net/bytes-out b/src/main/resources/util/net/bytes-out new file mode 100644 index 0000000..75f5ea9 Binary files /dev/null and b/src/main/resources/util/net/bytes-out differ diff --git a/src/main/resources/util/net/bytes-out.c b/src/main/resources/util/net/bytes-out.c new file mode 100644 index 0000000..1bd5ab6 --- /dev/null +++ b/src/main/resources/util/net/bytes-out.c @@ -0,0 +1,26 @@ +#include +#include + +#define BUFSIZE 128 + +int main() { + setuid(0); + char *cmd = "iptables -L MCSRV_OUT -nvx | tail -1 | grep -Po '^\\s*[0-9]+\\s+\\K([0-9]+)'"; + + char buf[BUFSIZE]; + FILE *fp; + + setuid(0); + if ((fp = popen(cmd, "r")) == NULL) { + return -1; + } + + fgets(buf, BUFSIZE, fp); + printf(buf); + + if (pclose(fp)) { + return -1; + } + + return 0; +} diff --git a/src/main/resources/util/net/bytes-reset b/src/main/resources/util/net/bytes-reset new file mode 100644 index 0000000..7d44470 Binary files /dev/null and b/src/main/resources/util/net/bytes-reset differ diff --git a/src/main/resources/util/net/bytes-reset.c b/src/main/resources/util/net/bytes-reset.c new file mode 100644 index 0000000..15db4c9 --- /dev/null +++ b/src/main/resources/util/net/bytes-reset.c @@ -0,0 +1,9 @@ +#include +#include + +int main() { + setuid(0); + system("iptables -L -Z MCSRV_OUT -v"); + system("iptables -L -Z MCSRV_IN -v"); + return 0; +} diff --git a/src/main/resources/util/net/setup.sh b/src/main/resources/util/net/setup.sh new file mode 100644 index 0000000..edb91e1 --- /dev/null +++ b/src/main/resources/util/net/setup.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +# Checks +if [ $# != 1 ]; then + echo "Skipping iptables due to missing server port number (or too many args), only setting file permissions." +fi + +if [ "$(id -u)" != "0" ]; then + echo "This script must be run as root!" + exit 1 +fi + +if ! command -v iptables >/dev/null 2>&1 +then + echo "iptables could not be found" + exit 1 +fi + +if ! command -v grep >/dev/null 2>&1 +then + echo "grep could not be found" + exit 1 +fi + +if [ "$(echo ' 9569 147232704 tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp spt:25565' | grep -Po '^\s*[0-9]+\s+\K([0-9]+)')" != "147232704" ]; then + echo "grep couldn't filter output" + exit 1 +fi + +# Setup +if [ $# = 1 ]; then + iptables -N MCSRV_OUT + iptables -A OUTPUT -j MCSRV_OUT + iptables -N MCSRV_IN + iptables -A INPUT -j MCSRV_IN + iptables -A MCSRV_OUT -p tcp --sport "$1" + iptables -A MCSRV_IN -p tcp --dport "$1" +fi +chown root: bytes-out +chown root: bytes-in +chown root: bytes-reset +chmod u+s bytes-out +chmod u+s bytes-in +chmod u+s bytes-reset +touch enabled + +echo "Setup done, bye!" diff --git a/worlds/world_normal/region/r.-1.-1.mca b/worlds/world_normal/region/r.-1.-1.mca new file mode 100644 index 0000000..a241c00 Binary files /dev/null and b/worlds/world_normal/region/r.-1.-1.mca differ diff --git a/worlds/world_normal/region/r.-1.0.mca b/worlds/world_normal/region/r.-1.0.mca new file mode 100644 index 0000000..01a117c Binary files /dev/null and b/worlds/world_normal/region/r.-1.0.mca differ diff --git a/worlds/world_normal/region/r.0.-1.mca b/worlds/world_normal/region/r.0.-1.mca new file mode 100644 index 0000000..942dbdc Binary files /dev/null and b/worlds/world_normal/region/r.0.-1.mca differ diff --git a/worlds/world_normal/region/r.0.0.mca b/worlds/world_normal/region/r.0.0.mca new file mode 100644 index 0000000..5c1f9a7 Binary files /dev/null and b/worlds/world_normal/region/r.0.0.mca differ