From bd32b9fab24d45a189eaa028a21e727c4028d3be Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 23 Apr 2026 23:01:11 +0200 Subject: [PATCH] chore: initial commit - LoMines v3.0.0 base structure - Java rewrite from Kotlin - LoAPI 3.0.0 integration - Paper 1.21 support - Core mine system with handlers - BlockSetter abstraction (Bukkit/Oraxen/ItemsAdder) - Statistics and leaderboard system - Group wand for batch mine creation - Mask fill mode support Known issues: - ConfigLoader.java: 712 lines (needs refactoring) - MineCommands.java: 341 lines (needs splitting) - 7 classes exceed 200 line limit - Code quality improvements needed (KISS, DRY, SOLID) --- .gitignore | 40 + ARCHITECTURE.md | 771 ++++++++++++++++++ build.gradle.kts | 88 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew.bat | 92 +++ libs/.gitkeep | 0 settings.gradle.kts | 1 + .../java/com/loki/lomines/LoMinesPlugin.java | 217 +++++ .../com/loki/lomines/block/BlockSetter.java | 37 + .../loki/lomines/block/BukkitBlockSetter.java | 105 +++ .../lomines/block/ItemsAdderBlockSetter.java | 100 +++ .../loki/lomines/block/OraxenBlockSetter.java | 100 +++ .../com/loki/lomines/block/package-info.java | 12 + .../loki/lomines/command/MineCommands.java | 341 ++++++++ .../loki/lomines/command/package-info.java | 13 + src/main/java/com/loki/lomines/core/Mine.java | 218 +++++ .../com/loki/lomines/core/MineTicker.java | 60 ++ .../java/com/loki/lomines/core/Mines.java | 247 ++++++ .../com/loki/lomines/core/package-info.java | 11 + .../com/loki/lomines/data/ConfigLoader.java | 712 ++++++++++++++++ .../lomines/data/ConfigParseException.java | 16 + .../java/com/loki/lomines/data/FillMode.java | 13 + .../com/loki/lomines/data/Leaderboard.java | 105 +++ .../loki/lomines/data/LeaderboardEntry.java | 45 + .../com/loki/lomines/data/MineConfig.java | 291 +++++++ .../com/loki/lomines/data/PlayerStats.java | 57 ++ .../java/com/loki/lomines/data/Reward.java | 70 ++ .../com/loki/lomines/data/StatsManager.java | 181 ++++ .../com/loki/lomines/data/package-info.java | 15 + .../com/loki/lomines/gui/GroupCreateGui.java | 210 +++++ .../lomines/gui/GroupCreateGuiHolder.java | 32 + .../com/loki/lomines/gui/package-info.java | 13 + .../lomines/handler/ActionBarHandler.java | 91 +++ .../lomines/handler/MineBlockHandler.java | 58 ++ .../lomines/handler/MineResetHandler.java | 152 ++++ .../lomines/handler/MineRewardHandler.java | 71 ++ .../loki/lomines/handler/package-info.java | 12 + .../integration/IntegrationManager.java | 50 ++ .../lomines/integration/package-info.java | 11 + .../lomines/listener/BlockBreakListener.java | 35 + .../lomines/listener/GroupGuiListener.java | 51 ++ .../listener/PlayerInteractListener.java | 73 ++ .../lomines/listener/PlayerJoinListener.java | 25 + .../loki/lomines/listener/package-info.java | 12 + .../java/com/loki/lomines/util/BlockKeys.java | 21 + .../com/loki/lomines/util/ChunkUtils.java | 185 +++++ .../java/com/loki/lomines/util/Cuboid.java | 182 +++++ .../com/loki/lomines/util/LocationParser.java | 96 +++ .../com/loki/lomines/util/MaskScanner.java | 45 + .../java/com/loki/lomines/util/Selection.java | 138 ++++ .../loki/lomines/util/SelectionManager.java | 86 ++ .../com/loki/lomines/util/TimeFormatter.java | 67 ++ .../com/loki/lomines/util/package-info.java | 14 + .../com/loki/lomines/wand/GroupWandItem.java | 59 ++ .../loki/lomines/wand/GroupWandManager.java | 21 + .../loki/lomines/wand/GroupWandSession.java | 82 ++ src/main/resources/config.yml | 13 + src/main/resources/plugin.yml | 44 + .../lomines/command/MineCommandsTest.java | 150 ++++ .../java/com/loki/lomines/core/MineTest.java | 120 +++ .../com/loki/lomines/core/MineTickerTest.java | 56 ++ .../java/com/loki/lomines/core/MinesTest.java | 401 +++++++++ .../loki/lomines/data/ConfigLoaderTest.java | 537 ++++++++++++ .../data/LeaderboardCacheIntegrationTest.java | 154 ++++ .../loki/lomines/data/LeaderboardTest.java | 204 +++++ .../loki/lomines/data/StatsManagerTest.java | 261 ++++++ .../com/loki/lomines/util/ChunkUtilsTest.java | 155 ++++ .../loki/lomines/util/LocationParserTest.java | 179 ++++ .../lomines/util/SelectionManagerTest.java | 128 +++ .../com/loki/lomines/util/SelectionTest.java | 222 +++++ .../loki/lomines/util/TimeFormatterTest.java | 175 ++++ 72 files changed, 8656 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew.bat create mode 100644 libs/.gitkeep create mode 100644 settings.gradle.kts create mode 100644 src/main/java/com/loki/lomines/LoMinesPlugin.java create mode 100644 src/main/java/com/loki/lomines/block/BlockSetter.java create mode 100644 src/main/java/com/loki/lomines/block/BukkitBlockSetter.java create mode 100644 src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java create mode 100644 src/main/java/com/loki/lomines/block/OraxenBlockSetter.java create mode 100644 src/main/java/com/loki/lomines/block/package-info.java create mode 100644 src/main/java/com/loki/lomines/command/MineCommands.java create mode 100644 src/main/java/com/loki/lomines/command/package-info.java create mode 100644 src/main/java/com/loki/lomines/core/Mine.java create mode 100644 src/main/java/com/loki/lomines/core/MineTicker.java create mode 100644 src/main/java/com/loki/lomines/core/Mines.java create mode 100644 src/main/java/com/loki/lomines/core/package-info.java create mode 100644 src/main/java/com/loki/lomines/data/ConfigLoader.java create mode 100644 src/main/java/com/loki/lomines/data/ConfigParseException.java create mode 100644 src/main/java/com/loki/lomines/data/FillMode.java create mode 100644 src/main/java/com/loki/lomines/data/Leaderboard.java create mode 100644 src/main/java/com/loki/lomines/data/LeaderboardEntry.java create mode 100644 src/main/java/com/loki/lomines/data/MineConfig.java create mode 100644 src/main/java/com/loki/lomines/data/PlayerStats.java create mode 100644 src/main/java/com/loki/lomines/data/Reward.java create mode 100644 src/main/java/com/loki/lomines/data/StatsManager.java create mode 100644 src/main/java/com/loki/lomines/data/package-info.java create mode 100644 src/main/java/com/loki/lomines/gui/GroupCreateGui.java create mode 100644 src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java create mode 100644 src/main/java/com/loki/lomines/gui/package-info.java create mode 100644 src/main/java/com/loki/lomines/handler/ActionBarHandler.java create mode 100644 src/main/java/com/loki/lomines/handler/MineBlockHandler.java create mode 100644 src/main/java/com/loki/lomines/handler/MineResetHandler.java create mode 100644 src/main/java/com/loki/lomines/handler/MineRewardHandler.java create mode 100644 src/main/java/com/loki/lomines/handler/package-info.java create mode 100644 src/main/java/com/loki/lomines/integration/IntegrationManager.java create mode 100644 src/main/java/com/loki/lomines/integration/package-info.java create mode 100644 src/main/java/com/loki/lomines/listener/BlockBreakListener.java create mode 100644 src/main/java/com/loki/lomines/listener/GroupGuiListener.java create mode 100644 src/main/java/com/loki/lomines/listener/PlayerInteractListener.java create mode 100644 src/main/java/com/loki/lomines/listener/PlayerJoinListener.java create mode 100644 src/main/java/com/loki/lomines/listener/package-info.java create mode 100644 src/main/java/com/loki/lomines/util/BlockKeys.java create mode 100644 src/main/java/com/loki/lomines/util/ChunkUtils.java create mode 100644 src/main/java/com/loki/lomines/util/Cuboid.java create mode 100644 src/main/java/com/loki/lomines/util/LocationParser.java create mode 100644 src/main/java/com/loki/lomines/util/MaskScanner.java create mode 100644 src/main/java/com/loki/lomines/util/Selection.java create mode 100644 src/main/java/com/loki/lomines/util/SelectionManager.java create mode 100644 src/main/java/com/loki/lomines/util/TimeFormatter.java create mode 100644 src/main/java/com/loki/lomines/util/package-info.java create mode 100644 src/main/java/com/loki/lomines/wand/GroupWandItem.java create mode 100644 src/main/java/com/loki/lomines/wand/GroupWandManager.java create mode 100644 src/main/java/com/loki/lomines/wand/GroupWandSession.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/test/java/com/loki/lomines/command/MineCommandsTest.java create mode 100644 src/test/java/com/loki/lomines/core/MineTest.java create mode 100644 src/test/java/com/loki/lomines/core/MineTickerTest.java create mode 100644 src/test/java/com/loki/lomines/core/MinesTest.java create mode 100644 src/test/java/com/loki/lomines/data/ConfigLoaderTest.java create mode 100644 src/test/java/com/loki/lomines/data/LeaderboardCacheIntegrationTest.java create mode 100644 src/test/java/com/loki/lomines/data/LeaderboardTest.java create mode 100644 src/test/java/com/loki/lomines/data/StatsManagerTest.java create mode 100644 src/test/java/com/loki/lomines/util/ChunkUtilsTest.java create mode 100644 src/test/java/com/loki/lomines/util/LocationParserTest.java create mode 100644 src/test/java/com/loki/lomines/util/SelectionManagerTest.java create mode 100644 src/test/java/com/loki/lomines/util/SelectionTest.java create mode 100644 src/test/java/com/loki/lomines/util/TimeFormatterTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56bf663 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Java +*.class +*.log +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +replay_pid* + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +.kiro/ +docs_api/ +api_docs/ +todo.md +bin/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..46d3c16 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,771 @@ +# ARCHITECTURE.md — LoMines (Java Rewrite) + +> **Лицензия:** GNU General Public License v3.0 +> Исходный код распространяется на условиях GPL-3. Любые производные работы обязаны публиковаться под той же лицензией. +> Полный текст: https://www.gnu.org/licenses/gpl-3.0.html + +## Обзор + +LoMines — Minecraft-плагин системы шахт, основанный на Paper API. +Данный документ описывает архитектуру **Java-версии** (переписана с Kotlin). +В качестве базовой библиотеки используется **LoAPI (lolib 2.0.1)**. + +Плагин реализует: +- Управление шахтами (создание, удаление, сброс, конфигурация) +- Мультирегиональные шахты (до 10 `Cuboid`-регионов, по 5 пар точек) +- Быструю/стандартную установку блоков с поддержкой Oraxen и ItemsAdder +- Систему наград при добыче блоков +- Статистику игроков и лидерборды +- Интеграции с PlaceholderAPI и WorldGuard +- GUI-редактор шахт +- Визуализацию выделения частицами + +--- + +## Зависимости + +| Зависимость | Scope | Откуда / Зачем | +|---|---|---| +| `paper-api:1.21-R0.1-SNAPSHOT` | compileOnly | Paper API | +| `lolib-2.0.1.jar` | implementation (shadow) | LoAPI — базовая библиотека (scheduler, commands, gui, items, utils, config, metrics, placeholders, performance) | +| `placeholderapi:2.11.6` | compileOnly | PlaceholderAPI | +| `worldguard-bukkit:7.0.9` | compileOnly | WorldGuard | +| `oraxen:1.161.0` | compileOnly | Oraxen custom blocks | +| `api-itemsadder:3.6.1` | compileOnly | ItemsAdder custom blocks | +| `commons-math3:3.6.1` | compileOnly | `EnumeratedDistribution` — взвешенный рандом для блоков и наград | +| `commons-io:2.17.0` | compileOnly | `FileUtils.listFiles()` для сканирования папки mines/ | +| `junit-jupiter:5.11.3` | testImplementation | JUnit тесты | +| `kotest-runner-junit5:5.9.1` | testImplementation | Property-based тесты | + +> LoAPI шейдится в JAR плагина. Relocate: `dev.lolib` → `com.loki.lomines.libs.lolib` + +--- + +## Структура пакетов + +``` +com.loki.lomines/ +│ +├── LoMinesPlugin.java # extends LoPlugin (LoAPI) +│ +├── commands/ +│ ├── LoMinesCommand.java # @Command("lm") — корневая команда /lm +│ ├── admin/ +│ │ ├── MineManagementCommands.java # create, delete, redefine +│ │ ├── MineControlCommands.java # reset, reload, list +│ │ └── DebugCommands.java # testtime, checktime +│ ├── player/ +│ │ ├── MineNavigationCommands.java # wand, tp, settp +│ │ ├── MineEditorCommands.java # editor +│ │ └── NotificationCommands.java # messages, notify +│ ├── region/ +│ │ └── WorldGuardCommands.java # region create/delete/process +│ └── stats/ +│ └── StatsCommands.java # stats, top, stats reset +│ +├── config/ +│ └── impl/ +│ ├── Config.java # config.yml — глобальные настройки +│ ├── Messages.java # messages.yml — локализация +│ └── MineConfig.java # mines/.yml — конфиг шахты +│ +├── mines/ +│ ├── Mine.java # Основной класс шахты +│ ├── Mines.java # Реестр всех шахт (static singleton) +│ ├── MineTicker.java # Тик всех шахт — Scheduler из LoAPI +│ │ +│ ├── handlers/ +│ │ ├── MineBlockHandler.java # Обработка BlockBreakEvent +│ │ ├── MineResetHandler.java # Логика сброса (fill + уведомления) +│ │ ├── MineRewardHandler.java # Выдача наград, парсинг конфига +│ │ ├── MineActionBarHandler.java # ActionBar — ActionBar из LoAPI utils +│ │ └── MineConfigLoader.java # Загрузка кубоидов, создание BlockSetter +│ │ +│ ├── setter/ +│ │ ├── BlockSetter.java # Абстрактный класс +│ │ ├── BukkitBlockSetter.java # Стандартный Bukkit (setBlockData) +│ │ ├── OraxenBlockSetter.java # Oraxen custom blocks +│ │ └── ItemsAdderBlockSetter.java # ItemsAdder custom blocks +│ │ +│ └── editor/ +│ ├── base/ +│ │ ├── BaseEditor.java # Базовый GUI — InventoryGUI из LoAPI +│ │ ├── BasePaginatedEditor.java # Пагинация — PagedGUI из LoAPI +│ │ └── EditorUtils.java +│ ├── MinesEditor.java +│ ├── MineEditor.java +│ ├── MineContentsEditor.java +│ ├── MineRewardsEditor.java +│ ├── MineRewardEditor.java +│ └── reward/ +│ ├── RewardCommandsEditor.java +│ ├── RewardItemsEditor.java +│ └── RewardBlocksEditor.java +│ +├── stats/ +│ ├── PlayerStats.java # Данные одного игрока (AtomicLong, ConcurrentHashMap) +│ ├── StatsManager.java # Центральный менеджер статистики +│ ├── StatsStorage.java # Чтение/запись stats.yml +│ └── Leaderboard.java # Топы по total и по шахтам +│ +├── selection/ +│ ├── Selection.java # До 5 пар точек (pos1..pos10), currentPair +│ ├── SelectionWand.java # Инструмент выделения + частицы +│ └── SelectionPairGui.java # GUI выбора пары — InventoryGUI из LoAPI +│ +├── integrations/ +│ ├── PlaceholderAPIIntegration.java # extends PlaceholderExpansion +│ ├── WorldGuardIntegration.java +│ └── WGRegionManager.java +│ +├── listener/ +│ └── BlockListener.java # BlockBreakEvent, PlayerInteractEvent, PlayerJoinEvent +│ +├── converters/ +│ └── CataMinesConverter.java # Конвертация из CataMines +│ +└── utils/ + ├── ChunkUtils.java # sendMultiBlockChange / sendBlockChange по чанкам + ├── FileUtils.java # Пути к папке плагина, копирование ресурсов + ├── NotificationManager.java # Отключение уведомлений — ConcurrentHashSet + yaml + └── TimeUtils.java # Форматирование времени — TimeFormatter из LoAPI +``` + +--- + +## Использование LoAPI + +### Главный класс — `LoPlugin` + +`LoMinesPlugin` наследует `LoPlugin` вместо `JavaPlugin`. + +```java +public class LoMinesPlugin extends LoPlugin { + + public static LoMinesPlugin INSTANCE; + + @Override + protected void enable() { + INSTANCE = this; + + loLogger().info("LoMines включается..."); + + // Определение активных интеграций + SimpleFeatureFlags features = SimpleFeatureFlags.create() + .enable("placeholderapi", Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) + .enable("worldguard", Bukkit.getPluginManager().getPlugin("WorldGuard") != null) + .enable("oraxen", Bukkit.getPluginManager().getPlugin("Oraxen") != null) + .enable("itemsadder", Bukkit.getPluginManager().getPlugin("ItemsAdder") != null) + .enable("statistics", Config.STATISTICS_ENABLED); + + // Команды через CommandManager из LoAPI + CommandManager commands = new CommandManager(this); + commands.registerTabCompleter("mine", ctx -> new ArrayList<>(Mines.getTypes().keySet())); + commands.register(new LoMinesCommand()); + commands.register(new MineManagementCommands()); + commands.register(new MineControlCommands()); + commands.register(new DebugCommands()); + commands.register(new MineNavigationCommands()); + commands.register(new MineEditorCommands()); + commands.register(new NotificationCommands()); + commands.register(new WorldGuardCommands()); + commands.register(new StatsCommands()); + + Bukkit.getPluginManager().registerEvents(new BlockListener(), this); + + features.ifEnabled("placeholderapi", () -> new PlaceholderAPIIntegration().register()); + features.ifEnabled("worldguard", this::initWorldGuard); + + // Метрики через LoAPI Metrics + features.ifEnabled("statistics", () -> { + Metrics metrics = new Metrics(this, 20058); + metrics.addCustomChart(new Metrics.SimplePie("mines_count", + () -> String.valueOf(Mines.getTypes().size()))); + }); + + MineTicker.schedule(this); + SelectionWand.startParticleTask(this); + NotificationManager.init(); + StatsManager.load(); + scheduleStatsSave(); + reload(); + + loLogger().info("LoMines v" + getDescription().getVersion() + " включен!"); + } + + @Override + protected void disable() { + SelectionWand.stopParticleTask(); + StatsManager.save(); + Scheduler.get(this).cancelAll(); + loLogger().info("LoMines выключен!"); + } + + @Override + protected void dependencies(DependencyManager manager) { + // Runtime-загрузка зависимостей которых нет в LoAPI + manager.add("org.apache.commons", "commons-math3", "3.6.1"); + manager.add("commons-io", "commons-io", "2.17.0"); + } + + public void reload() { + Config.reload(); + Mines.reload(); + } +} +``` + +--- + +### Scheduler + +`MineTicker` и все задержанные/асинхронные операции используют `Scheduler` из LoAPI. +Работает на Paper и Folia без изменений кода. + +```java +// MineTicker.java — тик всех шахт +public class MineTicker { + public static void schedule(Plugin plugin) { + Scheduler.get(plugin).runTimer(() -> + Mines.getTypes().forEach((name, mine) -> mine.tick()), 1L, 1L); + } +} + +// Частицы выделения каждые 10 тиков +Scheduler.get(plugin).runTimer(SelectionWand::tickParticles, 0L, 10L); + +// Автосохранение статистики (async, не блокирует main thread) +Scheduler.get(this).runTimerAsync(() -> StatsManager.save(), intervalTicks, intervalTicks); + +// Sync-коллбэк после завершения fill (вызывается из callback BlockSetter) +Scheduler.get(plugin).run(() -> { + executeResetCommands(); + sendResetNotifications(silent); + teleportPlayers(); + running.set(false); +}); + +// Обновление чанков с задержкой 2 тика +Scheduler.get(plugin).runLater(() -> ChunkUtils.refreshChunksForPlayers(cuboid), 2L); +``` + +--- + +### Команды — `CommandManager` + +Все команды используют аннотации LoAPI: `@Command`, `@Subcommand`, `@Arg`, `@Cooldown`, `@PlayerOnly`, `@Permission`. + +```java +@Command(value = "lm", aliases = {"lomines"}) +public class LoMinesCommand { + + @Default + public void onHelp(CommandSender sender) { /* help */ } + + @Subcommand(value = "reload", permission = "lomines.command.reload") + public void onReload(CommandSender sender) { + LoMinesPlugin.INSTANCE.reload(); + sender.sendMessage(Messages.RELOAD); + } + + @Subcommand(value = "list", permission = "lomines.command.list") + public void onList(CommandSender sender) { + Mines.getTypes().forEach((name, mine) -> sender.sendMessage("- " + name)); + } +} + +@Command(value = "lm") +public class MineControlCommands { + + @Subcommand(value = "reset", permission = "lomines.command.reset") + public void onReset(CommandSender sender, + @Arg(value = "mine", completer = "mine") String mineName) { + Mine mine = Mines.valueOf(mineName); + mine.reset(false); + } +} + +@Command(value = "lm") +public class MineNavigationCommands { + + @Subcommand(value = "tp", permission = "lomines.command.teleport") + @PlayerOnly + @Cooldown(seconds = 3) + public void onTp(Player player, + @Arg(value = "mine", completer = "mine") String mineName) { + Mine mine = Mines.valueOf(mineName); + player.teleport(Serializers.LOCATION.deserialize(mine.config.TELEPORT_LOCATION)); + } + + @Subcommand(value = "wand", permission = "lomines.command.wand") + @PlayerOnly + public void onWand(Player player) { + player.getInventory().addItem(SelectionWand.getWand()); + } +} + +@Command(value = "lm") +public class StatsCommands { + + @Subcommand(value = "stats", permission = "lomines.command.stats") + @PlayerOnly + public void onStats(Player player) { + PlayerStats stats = StatsManager.getStats(player); + player.sendMessage("Всего сломано: " + stats.getTotalBlocks()); + } + + @Subcommand(value = "top", permission = "lomines.command.top") + public void onTop(CommandSender sender) { + Leaderboard.getTopTotal(10).forEach(entry -> + sender.sendMessage(entry.position() + ". " + entry.playerName() + " — " + entry.blocks())); + } +} +``` + +--- + +### GUI — `InventoryGUI` + +Редакторы шахт используют `InventoryGUI` из LoAPI. + +```java +// BaseEditor.java +public abstract class BaseEditor { + protected final Player player; + protected final InventoryGUI gui; // dev.lolib.gui.InventoryGUI + + public BaseEditor(Player player, String title, int rows) { + this.player = player; + this.gui = new InventoryGUI(LoMinesPlugin.INSTANCE, Component.text(title), rows); + } + + protected void setItem(int slot, ItemStack item, Consumer onClick) { + gui.setItem(slot, item, onClick::accept); + } + + public void open() { + buildItems(); + gui.open(player); + } + + protected abstract void buildItems(); +} + +// MineContentsEditor.java +public class MineContentsEditor extends BaseEditor { + public MineContentsEditor(Player player, Mine mine) { + super(player, "Блоки: " + mine.name, 6); + } + + @Override + protected void buildItems() { + setItem(49, ItemBuilder.of(Material.LIME_DYE).name("Добавить").build(), + p -> openAddBlockMenu(p)); + // ... слоты с блоками + } +} +``` + +--- + +### ItemBuilder + +```java +import dev.lolib.items.ItemBuilder; + +// Кнопка сброса +ItemStack btn = ItemBuilder.of(Material.REDSTONE) + .name("Сбросить шахту") + .lore("Заполнение: " + percent + "%") + .build(); + +// Заглушка +ItemStack filler = ItemBuilder.of(Material.GRAY_STAINED_GLASS_PANE).name(" ").build(); + +// Палочка выделения +ItemStack wand = ItemBuilder.of(Material.GOLDEN_AXE) + .name("<#00AAFF>Палочка выделения") + .lore("<#00AAFF>ЛКМ — позиция #1") + .lore("<#00AAFF>ПКМ — позиция #2") + .lore("<#00AAFF>Shift+ПКМ — выбор региона") + .persistentData(wandKey, PersistentDataType.BYTE, (byte) 0) + .build(); +``` + +--- + +### Логирование — `LoLogger` + +```java +// Вместо getLogger() везде используем loLogger() +private static final LoLogger LOGGER = LoMinesPlugin.INSTANCE.loLogger(); + +LOGGER.info("Шахта " + name + " создана, объём: " + volume); +LOGGER.warn("Мир не найден для шахты " + name); +LOGGER.error("Не удалось создать BlockSetter", e); +if (Config.DEBUG) LOGGER.debug("Сброс занял " + took + "мс"); +``` + +--- + +### Форматирование — `StringUtils` / `TimeFormatter` + +```java +import dev.lolib.utils.StringUtils; +import dev.lolib.utils.TimeFormatter; + +// MiniMessage строки с плейсхолдерами +String msg = StringUtils.format(mine.config.ACTION_BAR, + "notbroken", String.valueOf(blocks), + "total", String.valueOf(mine.volume), + "percent", String.format("%.2f", blocks / mine.volume * 100.0), + "blocksbroken", String.format("%.2f", mine.volume - blocks), + "time", formatTime(remainingMs, mine.config.TIMER_FORMAT) +); + +// Форматирование времени по TIMER_FORMAT +private String formatTime(long ms, int format) { + Duration d = Duration.ofMillis(ms); + return switch (format) { + case 1 -> TimeFormatter.formatDigital(d); // "01:25:35" + case 2 -> TimeFormatter.formatShort(d); // "1ч 25м" + default -> TimeFormatter.formatSmart(d); // "01ч 25м 35с" + }; +} +``` + +--- + +## Ключевые классы + +### `Mine.java` + +Координирует handler-ы. Никакой логики — только делегирование. + +```java +public class Mine { + public final String name; + public final MineConfig config; + public final List cuboids; // до 5 кубоидов + + private final MineBlockHandler blockHandler; + private final MineResetHandler resetHandler; + private final MineRewardHandler rewardHandler; + private final MineActionBarHandler actionBarHandler; + private final MineConfigLoader configLoader; + + private BlockSetter placer; + public double volume; // суммарный объём всех кубоидов + public double blocks; // текущее кол-во неломаных блоков + private long tick; // тик с последнего сброса + + public void tick() { ... } + public void onBlockBreak(Player player, Block block, BlockBreakEvent event) { ... } + public void reset(boolean silent) { ... } + public void reload(boolean reset) { ... } + public boolean containsLocation(Location loc) { ... } // any cuboid + public double getDistanceToMine(Location loc) { ... } // min dist to any cuboid + public World getWorld() { ... } // world of first cuboid +} +``` + +### `Mines.java` + +```java +public final class Mines { + private static final Map TYPES = new HashMap<>(); + // ключ = mine.name.toLowerCase() + + public static void reload() { ... } + public static void register(Mine mine) { ... } + public static void unregister(Mine mine) { ... } // удаляет + файл конфига + public static Mine valueOf(String name) { ... } // throws IllegalArgumentException + public static Map getTypes() { ... } +} +``` + +### `BlockSetter.java` + +```java +public abstract class BlockSetter { + protected final World world; + protected final EnumeratedDistribution distribution; // commons-math3 + + public abstract void fill(Cuboid cuboid, IntConsumer consumer); + // consumer.accept(blocksPlaced) — вызывается после завершения заполнения +} +``` + +Выбор реализации в `MineConfigLoader.createBlockSetter()`: +- ключ в `contents` содержит `oraxen:` → `OraxenBlockSetter` +- ключ содержит `itemsadder:` → `ItemsAdderBlockSetter` +- иначе → `BukkitBlockSetter` (vanilla) + +### `MineResetHandler.java` + +```java +public class MineResetHandler { + private final AtomicBoolean running = new AtomicBoolean(false); + + public void reset(boolean silent, BlockSetter placer) { + if (!running.compareAndSet(false, true)) return; // защита от двойного сброса + // для каждого cuboid: placer.fill(cub, callback) + // когда ВСЕ кубоиды завершены (AtomicInteger completedCuboids): + // Scheduler.get(plugin).run(() -> постобработка + running.set(false)) + } +} +``` + +### `MineActionBarHandler.java` + +Каждые 10 тиков отправляет action bar игрокам в радиусе `ACTION_BAR_RANGE²`. + +```java +// Отправка через ActionBar из LoAPI utils +import dev.lolib.utils.ActionBar; + +ActionBar.send(player, StringUtils.format(mine.config.ACTION_BAR, + "notbroken", ..., "total", ..., "percent", ..., "time", ...)); +``` + +### `PlayerStats.java` + +```java +public class PlayerStats { + public final UUID uuid; + public String playerName; + private final AtomicLong totalBlocks = new AtomicLong(0); + private final ConcurrentHashMap mineStats = new ConcurrentHashMap<>(); + // ключ = mineName.toLowerCase() + + public void incrementTotal() { ... } + public void incrementMine(String mineName) { ... } // computeIfAbsent → new AtomicLong + public long getMineStat(String mineName) { ... } + public void resetMine(String mineName) { ... } // вычитает из total, мин 0 + public void setTotalBlocks(long count) { ... } // только для десериализации + public void setMineStat(String mineName, long count) { ... } +} +``` + +### `Selection.java` + +Хранит до 5 пар точек. Ключ в `Map` = `(pair-1)*2 + (first?1:2)`. + +``` +pair=1: pos[1], pos[2] +pair=2: pos[3], pos[4] +... +pair=5: pos[9], pos[10] +currentPair — активная пара (переключается через SelectionPairGui) +``` + +--- + +## Поток данных при разрушении блока + +``` +BlockBreakEvent (MONITOR, ignoreCancelled=true) + └── BlockListener.onBlockBreakEvent() + └── for mine : Mines.getTypes().values() + if mine.containsLocation(block.location): + ├── mine.onBlockBreak(player, block, event) + │ ├── MineBlockHandler: blocks-- + │ ├── MineRewardHandler.getRandomRewards(block) + │ │ └── rand <= chance/100 → reward.execute(player) + │ │ ├── dispatchCommand (от консоли) + │ │ └── player.inventory.addItem(items) + │ └── if blocks/volume*100 <= RESET_PERCENT + │ && Config.RESET_ON_PERCENT_ENABLED → mine.reset() + │ + └── StatsManager.incrementStats(player, mine) + └── playerStats.incrementTotal() + incrementMine(mine.name) +``` + +--- + +## Поток сброса шахты + +``` +mine.reset(silent) + ├── tick = 0 + ├── actionBarHandler.reset() + ├── if resetHandler.isRunning() → return (защита от двойного сброса) + ├── mine.blocks = mine.volume (немедленно, до завершения fill) + └── resetHandler.reset(silent, placer) + ├── running.compareAndSet(false, true) + ├── for each cuboid: + │ placer.fill(cuboid, placed -> { + │ if (++completed >= total): + │ Scheduler.get(plugin).run(() -> { + │ executeResetCommands() + │ sendResetNotifications(silent) + │ teleportPlayers() + │ running.set(false) + │ }) + │ }) + └── после fill → ChunkUtils.refreshChunksDelayed(cuboid, 2L) +``` + +--- + +## Поток загрузки плагина + +``` +LoMinesPlugin.enable() [extends LoPlugin] + ├── LoPlugin.onLoad() → dependencies(manager) // commons-math3, commons-io + ├── loLogger().info(...) + ├── SimpleFeatureFlags → определение активных интеграций + ├── CommandManager(this) + │ .registerTabCompleter("mine", ...) + │ .register(9 классов команд) + ├── Bukkit.registerEvents(new BlockListener(), this) + ├── features.ifEnabled("placeholderapi") → register() + ├── features.ifEnabled("statistics") → new Metrics(this, 20058) + ├── MineTicker.schedule(this) // Scheduler.runTimer(1L, 1L) + ├── SelectionWand.startParticleTask(this) // Scheduler.runTimer(0L, 10L) + ├── NotificationManager.init() + ├── StatsManager.load() + ├── scheduleStatsSave() // Scheduler.runTimerAsync(...) + └── reload() + ├── Config.reload() + └── Mines.reload() +``` + +--- + +## Жизненный цикл Mine-объекта + +``` +new Mine(file, reset=true) + ├── Mines.register(this) + └── reload(reset=true) + ├── config.reload() + ├── cuboids = configLoader.loadCuboids() + ├── volume = configLoader.calculateVolume(cuboids) + ├── blocks = volume + ├── rewardHandler.loadRewards() + ├── placer = configLoader.createBlockSetter(world) + └── if reset: mine.reset() + +Mine.tick() [каждый тик] + ├── tick++ + ├── if tick >= RESET_TICKS → reset(false); tick = 0 + └── actionBarHandler.tick(tick, blocks) + +Mines.unregister(mine) + ├── TYPES.remove(mine.name) + └── mine.config.file.delete() +``` + +--- + +## Конфигурация шахты (MineConfig) + +Файл: `plugins/LoMines/mines/.yml` + +| Поле | Ключ в yaml | Тип | Описание | +|---|---|---|---| +| `DISPLAY_NAME` | `display-name` | String | MiniMessage имя | +| `CONTENTS` | `contents` | Map | блок → вес (нормализует EnumeratedDistribution) | +| `SELECTION_CORNER_1..10` | `selection.1..10` | String | `"world;x;y;z;yaw;pitch"` | +| `RESET_TICKS` | `reset.ticks` | long | тики до автосброса (20 = 1 сек) | +| `RESET_PERCENT` | `reset.percent` | double | % для автосброса по проценту | +| `BROADCAST_RESET` | `broadcast-reset` | int | -2=откл, -1=все, 0=мир, N=радиус | +| `TELEPORT_ON_RESET` | `teleport-on-reset` | int | 0=верхний блок, 1=settp, иное=откл | +| `TELEPORT_LOCATION` | `teleport-location` | String | `"world;x;y;z;yaw;pitch"` | +| `ACTION_BAR_ENABLED` | `actionbar.enabled` | boolean | показывать action bar | +| `ACTION_BAR_RANGE` | `actionbar.range` | int | радиус (проверяется как range²) | +| `TIMER_FORMAT` | `timer-format` | int | 1=digital, 2=short, 3=full | +| ~~`SETTER`~~ | ~~`setter`~~ | ~~String~~ | ~~Удалено (только Bukkit API)~~ | +| `RANDOM_REWARDS` | `random-rewards` | List\ | список наград | +| `RESET_COMMANDS` | `reset-commands` | List\ | команды после сброса | + +### Структура блока награды + +```yaml +- chance: 0.01 + prevent-drops: false + blocks: + - "diamond_ore" + items: + - type: diamond + amount: 1 + name: "Редкий алмаз" + commands: + - "eco give 500" +``` + +--- + +## Интеграции + +### PlaceholderAPI + +| Плейсхолдер | Возвращает | +|---|---| +| `%lomines_percent_%` | `blocks/volume*100` (2 знака) | +| `%lomines_notbroken_%` | `mine.blocks` | +| `%lomines_blocksbroken_%` | `volume - blocks` | +| `%lomines_total_%` | `mine.volume` | +| `%lomines_resettime_%` | время до сброса | +| `%lomines_stats_total%` | `playerStats.totalBlocks` | +| `%lomines_stats_%` | `playerStats.getMineStat(mine)` | +| `%lomines_top__name%` | имя в общем топе | +| `%lomines_top__count%` | блоков в общем топе | +| `%lomines_top___name%` | имя в топе по шахте | +| `%lomines_top___count%` | блоков в топе по шахте | + +### WorldGuard + +`WGRegionManager` создаёт/удаляет WG-регион при операциях с шахтой. +Настройки: `Config.WORLDGUARD_ENABLED`, `WORLDGUARD_AUTO_CREATE_REGIONS`, `WORLDGUARD_REGION_PRIORITY`. + +--- + +## Система статистики + +``` +StatsManager +├── ConcurrentHashMap stats +├── load() ← enable() +├── save() ← по таймеру Scheduler.runTimerAsync + disable() +└── incrementStats(Player, Mine) + ├── if !Config.STATISTICS_ENABLED → return + └── stats.computeIfAbsent(uuid) → incrementTotal() + incrementMine(mine.name) + +Leaderboard (stateless) +├── getTopTotal(limit) → сортировка по totalBlocks desc +├── getTopByMine(mine, limit) → фильтр + сортировка по getMineStat(mine) desc +├── getPosition(uuid) → 1-based, -1 если нет +└── getPositionByMine(uuid, mine) +``` + +--- + +## Принципы + +**SOLID:** +- `Mine` — координатор без логики, делегирует в handler-ы (SRP) +- `BlockSetter` — новые реализации добавляются наследованием (OCP) +- Все `BlockSetter` взаимозаменяемы в `MineResetHandler` (LSP) +- Handler-классы зависят только от нужного (ISP) +- `Mine` зависит от абстракции `BlockSetter`, не от конкретного NMS (DIP) + +**Ограничения на код:** +- Максимум 200 строк на файл (кроме сгенерированных/библиотечных) +- Асинхронность — только через `Scheduler.get(plugin)`, никогда `new Thread()` +- Bukkit API — только в main thread (кроме async-save статистики) +- Изменяемое static состояние — только в `Mines.TYPES` и `StatsManager.stats` + +--- + +## Известные проблемы + +| # | Проблема | Файл | Приоритет | +|---|---|---|---| +| 1 | `Leaderboard.getTopTotal()` — O(n log n) без кэша | `Leaderboard.java` | средний | +| 2 | `mine.blocks = mine.volume` выставляется до завершения fill — ложный процент-reset во время сброса | `Mine.java` | средний | +| 3 | `MineResetHandler.reset()` принимает `BlockSetter` параметром — лишняя связность | `MineResetHandler.java` | низкий | +| 4 | Нет тестов на `MineResetHandler`, `MineBlockHandler`, `BlockListener`, `Mines` | `test/` | высокий | +| 5 | `ChunkUtils` определяет Paper через `Class.forName` — хрупко | `ChunkUtils.java` | низкий | +| 6 | `SelectionWand.drawLine` не ограничивает частицы по Y — спам для высоких шахт | `SelectionWand.java` | низкий | diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7988bc0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + java + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +group = "com.loki" +version = "3.0.0" +description = "Multi-region mine management plugin for Paper" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") +} + +// LoAPI: libs/lolib*.jar — fileTree индексируется Gradle / Java LS стабильнее, чем files(singleJar) +val lolibTree = fileTree("libs") { include("lolib*.jar") } +if (lolibTree.files.isEmpty()) { + throw GradleException( + "Missing libs/lolib*.jar — add LoAPI fat jar to ${rootProject.file("libs").absolutePath}" + ) +} + +dependencies { + // Paper API 1.21 + compileOnly("io.papermc.paper:paper-api:1.21-R0.1-SNAPSHOT") + + // LoAPI (local) + implementation(lolibTree) + + // Runtime dependencies (loaded via DependencyManager from LoAPI) + // These are marked as compileOnly because they will be downloaded at runtime + compileOnly("org.apache.commons:commons-math3:3.6.1") + compileOnly("commons-io:commons-io:2.17.0") + + // Optional plugin integrations (soft dependencies) + compileOnly("com.github.oraxen:oraxen:1.171.0") + compileOnly("com.github.LoneDev6:api-itemsadder:3.6.1") + + // Test dependencies + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("io.kotest:kotest-runner-junit5:5.7.2") + testImplementation("io.kotest:kotest-assertions-core:5.7.2") + testImplementation("io.kotest:kotest-property:5.7.2") + testImplementation("org.mockito:mockito-core:5.7.0") + testImplementation("org.mockito:mockito-inline:5.2.0") + testImplementation("io.papermc.paper:paper-api:1.21-R0.1-SNAPSHOT") + testImplementation("org.apache.commons:commons-math3:3.6.1") + testImplementation("commons-io:commons-io:2.17.0") +} + +tasks { + shadowJar { + archiveClassifier.set("") + + // Relocate LoAPI to avoid conflicts + relocate("dev.lolib", "com.loki.lomines.libs.lolib") + + // Don't minimize - causes issues with Java 21 + // minimize() + } + + build { + dependsOn(shadowJar) + } + + test { + useJUnitPlatform() + } + + processResources { + val props = mapOf( + "version" to version, + "description" to description + ) + inputs.properties(props) + filteringCharset = "UTF-8" + filesMatching("plugin.yml") { + expand(props) + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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=. +@rem This is normally unused +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% equ 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% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/.gitkeep b/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b3ba619 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "LoMines" diff --git a/src/main/java/com/loki/lomines/LoMinesPlugin.java b/src/main/java/com/loki/lomines/LoMinesPlugin.java new file mode 100644 index 0000000..f13dfea --- /dev/null +++ b/src/main/java/com/loki/lomines/LoMinesPlugin.java @@ -0,0 +1,217 @@ +package com.loki.lomines; + +import dev.lolib.core.LoPlugin; +import dev.lolib.commands.CommandManager; +import dev.lolib.core.dependency.DependencyManager; +import com.loki.lomines.core.MineTicker; +import com.loki.lomines.core.Mines; +import com.loki.lomines.data.StatsManager; +import com.loki.lomines.integration.IntegrationManager; +import com.loki.lomines.listener.BlockBreakListener; +import com.loki.lomines.listener.GroupGuiListener; +import com.loki.lomines.listener.PlayerInteractListener; +import com.loki.lomines.listener.PlayerJoinListener; +import com.loki.lomines.wand.GroupWandManager; +import org.bukkit.configuration.file.FileConfiguration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Main plugin class for LoMines. + * Extends LoPlugin from LoAPI for Paper/Folia compatibility. + */ +public final class LoMinesPlugin extends LoPlugin { + + private Mines mines; + private MineTicker mineTicker; + private GroupWandManager groupWandManager; + private StatsManager statsManager; + private CommandManager commandManager; + private IntegrationManager integrationManager; + + @Override + protected void enable() { + try { + // Save default config if not exists + saveDefaultConfig(); + + // Create necessary directories + createDirectories(); + + // Initialize core components + initializeComponents(); + + // Load all mines from mines/ folder + loadMines(); + + this.mineTicker = new MineTicker(mines, this); + mineTicker.start(); + + // Register commands + registerCommands(); + + // Register event listeners + registerListeners(); + + // Initialize integrations (PlaceholderAPI, WorldGuard, etc.) + initializeIntegrations(); + + // Start statistics auto-save if enabled + startStatisticsAutoSave(); + + loLogger().info("LoMines has been enabled!"); + } catch (Exception e) { + loLogger().error("Failed to enable LoMines: " + e.getMessage()); + e.printStackTrace(); + getServer().getPluginManager().disablePlugin(this); + } + } + + @Override + protected void disable() { + try { + if (mineTicker != null) { + mineTicker.stop(); + mineTicker = null; + } + // Stop all mine tasks + if (mines != null) { + mines.getAll().forEach(mine -> mine.stop()); + } + + // Save statistics + if (statsManager != null) { + statsManager.stopAutoSave(); + statsManager.save(); + loLogger().info("Statistics saved successfully"); + } + + loLogger().info("LoMines has been disabled!"); + } catch (Exception e) { + loLogger().error("Error during plugin shutdown: " + e.getMessage()); + e.printStackTrace(); + } + } + + @Override + @SuppressWarnings("removal") + protected void dependencies(DependencyManager dependencyManager) { + // Load Apache Commons Math3 for EnumeratedDistribution + dependencyManager.add("org.apache.commons", "commons-math3", "3.6.1"); + + // Load Apache Commons IO for file utilities + dependencyManager.add("commons-io", "commons-io", "2.17.0"); + } + + /** + * Creates necessary directories for plugin data. + */ + private void createDirectories() throws IOException { + Path dataFolder = getDataFolder().toPath(); + Path minesFolder = dataFolder.resolve("mines"); + + if (!Files.exists(minesFolder)) { + Files.createDirectories(minesFolder); + loLogger().info("Created mines directory"); + } + } + + /** + * Initializes core plugin components. + */ + private void initializeComponents() { + this.mines = new Mines(this); + this.groupWandManager = new GroupWandManager(); + this.statsManager = new StatsManager(this); + this.commandManager = new CommandManager(this); + this.integrationManager = new IntegrationManager(this); + + loLogger().info("Core components initialized"); + } + + /** + * Loads all mines from the mines/ folder. + */ + private void loadMines() { + try { + mines.loadAll(); + loLogger().info("Loaded " + mines.getAll().size() + " mine(s)"); + } catch (IOException e) { + loLogger().error("Failed to load mines: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Registers all plugin commands. + */ + private void registerCommands() { + commandManager.register(new com.loki.lomines.command.MineCommands(this)); + + loLogger().info("Commands registered"); + } + + /** + * Registers all event listeners. + */ + private void registerListeners() { + getServer().getPluginManager().registerEvents(new BlockBreakListener(this), this); + getServer().getPluginManager().registerEvents(new PlayerInteractListener(this), this); + getServer().getPluginManager().registerEvents(new GroupGuiListener(this), this); + + FileConfiguration config = getConfig(); + if (config.getBoolean("statistics-enabled", true)) { + getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this); + } + + loLogger().info("Event listeners registered"); + } + + /** + * Initializes integrations with external plugins. + */ + private void initializeIntegrations() { + integrationManager.initAll(); + loLogger().info("Integrations initialized"); + } + + /** + * Starts statistics auto-save if enabled in config. + */ + private void startStatisticsAutoSave() { + FileConfiguration config = getConfig(); + if (config.getBoolean("statistics-enabled", true)) { + try { + statsManager.load(); + statsManager.startAutoSave(); + loLogger().info("Statistics system enabled"); + } catch (IOException e) { + loLogger().warn("Failed to load statistics: " + e.getMessage()); + } + } + } + + // Getters for component access + + public Mines getMines() { + return mines; + } + + public GroupWandManager getGroupWandManager() { + return groupWandManager; + } + + public StatsManager getStatsManager() { + return statsManager; + } + + public CommandManager getCommandManager() { + return commandManager; + } + + public IntegrationManager getIntegrationManager() { + return integrationManager; + } +} diff --git a/src/main/java/com/loki/lomines/block/BlockSetter.java b/src/main/java/com/loki/lomines/block/BlockSetter.java new file mode 100644 index 0000000..77a3885 --- /dev/null +++ b/src/main/java/com/loki/lomines/block/BlockSetter.java @@ -0,0 +1,37 @@ +package com.loki.lomines.block; + +import com.loki.lomines.util.Cuboid; +import org.bukkit.Location; + +import java.util.List; +import java.util.function.IntConsumer; + +/** + * Abstract base class for block setting strategies. + * Provides a template for filling mine regions with blocks. + * + *

Implementations:

+ *
    + *
  • BukkitBlockSetter - For vanilla Minecraft blocks
  • + *
  • OraxenBlockSetter - For Oraxen custom blocks
  • + *
  • ItemsAdderBlockSetter - For ItemsAdder custom blocks
  • + *
+ * + *

This is a placeholder that will be fully implemented in task 2.7.

+ */ +public abstract class BlockSetter { + + /** + * Fills a cuboid region with blocks asynchronously. + * The callback is invoked in the main thread after completion. + * + * @param region The region to fill + * @param callback Called with the number of blocks set when complete + */ + public abstract void fill(Cuboid region, IntConsumer callback); + + /** + * Places random weighted blocks only at the given locations (mask mode). + */ + public abstract void fillAtLocations(List locations, IntConsumer callback); +} diff --git a/src/main/java/com/loki/lomines/block/BukkitBlockSetter.java b/src/main/java/com/loki/lomines/block/BukkitBlockSetter.java new file mode 100644 index 0000000..42f5983 --- /dev/null +++ b/src/main/java/com/loki/lomines/block/BukkitBlockSetter.java @@ -0,0 +1,105 @@ +package com.loki.lomines.block; + +import dev.lolib.scheduler.Scheduler; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.util.Cuboid; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.data.BlockData; + +import org.bukkit.Location; + +import java.util.List; +import java.util.Map; +import java.util.function.IntConsumer; + +/** + * BlockSetter implementation for vanilla Minecraft blocks. + * Uses Bukkit API to set blocks with optimal performance settings. + */ +public final class BukkitBlockSetter extends BlockSetter { + + private final Map weights; + private final LoMinesPlugin plugin; + + public BukkitBlockSetter(Map weights, LoMinesPlugin plugin) { + this.weights = weights; + this.plugin = plugin; + } + + @Override + public void fill(Cuboid region, IntConsumer callback) { + Scheduler.get(plugin).runAsync(() -> { + int count = fillSync(region); + + Scheduler.get(plugin).run(() -> { + callback.accept(count); + }); + }); + } + + @Override + public void fillAtLocations(List locations, IntConsumer callback) { + if (locations == null || locations.isEmpty()) { + Scheduler.get(plugin).run(() -> callback.accept(0)); + return; + } + Scheduler.get(plugin).runAsync(() -> { + int count = fillAtLocationsSync(locations); + Scheduler.get(plugin).run(() -> callback.accept(count)); + }); + } + + private int fillSync(Cuboid region) { + int count = 0; + World world = region.getWorld(); + + for (int x = region.getMinX(); x <= region.getMaxX(); x++) { + for (int y = region.getMinY(); y <= region.getMaxY(); y++) { + for (int z = region.getMinZ(); z <= region.getMaxZ(); z++) { + BlockData data = sampleBlockData(); + world.getBlockAt(x, y, z).setBlockData(data, false); + count++; + } + } + } + + return count; + } + + private int fillAtLocationsSync(List locations) { + int count = 0; + for (Location loc : locations) { + if (loc.getWorld() == null) { + continue; + } + BlockData data = sampleBlockData(); + loc.getBlock().setBlockData(data, false); + count++; + } + return count; + } + + private BlockData sampleBlockData() { + // Simple weighted random selection + double totalWeight = weights.values().stream().mapToDouble(Double::doubleValue).sum(); + double random = Math.random() * totalWeight; + + double currentWeight = 0; + for (Map.Entry entry : weights.entrySet()) { + currentWeight += entry.getValue(); + if (random <= currentWeight) { + // Convert string key to BlockData + Material material = Material.valueOf(entry.getKey().toUpperCase()); + return Bukkit.createBlockData(material); + } + } + + // Fallback to first entry + String firstKey = weights.keySet().iterator().next(); + Material material = Material.valueOf(firstKey.toUpperCase()); + return Bukkit.createBlockData(material); + } +} diff --git a/src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java b/src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java new file mode 100644 index 0000000..303560f --- /dev/null +++ b/src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java @@ -0,0 +1,100 @@ +package com.loki.lomines.block; + +import dev.lolib.scheduler.Scheduler; +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.util.Cuboid; +import dev.lone.itemsadder.api.CustomBlock; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; + +import java.util.List; +import java.util.Map; +import java.util.function.IntConsumer; + +/** + * BlockSetter implementation for ItemsAdder custom blocks. + * Uses ItemsAdder API to place custom blocks in mine regions. + */ +public final class ItemsAdderBlockSetter extends BlockSetter { + + private final Map itemsAdderIds; + private final LoMinesPlugin plugin; + + public ItemsAdderBlockSetter(Map itemsAdderIds, LoMinesPlugin plugin) { + this.itemsAdderIds = itemsAdderIds; + this.plugin = plugin; + } + + @Override + public void fill(Cuboid region, IntConsumer callback) { + Scheduler.get(plugin).runAsync(() -> { + int count = fillSync(region); + + Scheduler.get(plugin).run(() -> { + callback.accept(count); + }); + }); + } + + @Override + public void fillAtLocations(List locations, IntConsumer callback) { + if (locations == null || locations.isEmpty()) { + Scheduler.get(plugin).run(() -> callback.accept(0)); + return; + } + Scheduler.get(plugin).runAsync(() -> { + int count = fillAtLocationsSync(locations); + Scheduler.get(plugin).run(() -> callback.accept(count)); + }); + } + + private int fillSync(Cuboid region) { + int count = 0; + World world = region.getWorld(); + + for (int x = region.getMinX(); x <= region.getMaxX(); x++) { + for (int y = region.getMinY(); y <= region.getMaxY(); y++) { + for (int z = region.getMinZ(); z <= region.getMaxZ(); z++) { + String itemsAdderId = sampleItemsAdderId(); + Block block = world.getBlockAt(x, y, z); + CustomBlock.place(itemsAdderId, block.getLocation()); + count++; + } + } + } + + return count; + } + + private int fillAtLocationsSync(List locations) { + int count = 0; + for (Location loc : locations) { + if (loc.getWorld() == null) { + continue; + } + String itemsAdderId = sampleItemsAdderId(); + Block block = loc.getBlock(); + CustomBlock.place(itemsAdderId, block.getLocation()); + count++; + } + return count; + } + + private String sampleItemsAdderId() { + // Simple weighted random selection + double totalWeight = itemsAdderIds.values().stream().mapToDouble(Double::doubleValue).sum(); + double random = Math.random() * totalWeight; + + double currentWeight = 0; + for (Map.Entry entry : itemsAdderIds.entrySet()) { + currentWeight += entry.getValue(); + if (random <= currentWeight) { + return entry.getKey(); + } + } + + // Fallback to first entry + return itemsAdderIds.keySet().iterator().next(); + } +} diff --git a/src/main/java/com/loki/lomines/block/OraxenBlockSetter.java b/src/main/java/com/loki/lomines/block/OraxenBlockSetter.java new file mode 100644 index 0000000..5652382 --- /dev/null +++ b/src/main/java/com/loki/lomines/block/OraxenBlockSetter.java @@ -0,0 +1,100 @@ +package com.loki.lomines.block; + +import dev.lolib.scheduler.Scheduler; +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.util.Cuboid; +import io.th0rgal.oraxen.api.OraxenBlocks; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; + +import java.util.List; +import java.util.Map; +import java.util.function.IntConsumer; + +/** + * BlockSetter implementation for Oraxen custom blocks. + * Uses Oraxen API to place custom blocks in mine regions. + */ +public final class OraxenBlockSetter extends BlockSetter { + + private final Map oraxenIds; + private final LoMinesPlugin plugin; + + public OraxenBlockSetter(Map oraxenIds, LoMinesPlugin plugin) { + this.oraxenIds = oraxenIds; + this.plugin = plugin; + } + + @Override + public void fill(Cuboid region, IntConsumer callback) { + Scheduler.get(plugin).runAsync(() -> { + int count = fillSync(region); + + Scheduler.get(plugin).run(() -> { + callback.accept(count); + }); + }); + } + + @Override + public void fillAtLocations(List locations, IntConsumer callback) { + if (locations == null || locations.isEmpty()) { + Scheduler.get(plugin).run(() -> callback.accept(0)); + return; + } + Scheduler.get(plugin).runAsync(() -> { + int count = fillAtLocationsSync(locations); + Scheduler.get(plugin).run(() -> callback.accept(count)); + }); + } + + private int fillSync(Cuboid region) { + int count = 0; + World world = region.getWorld(); + + for (int x = region.getMinX(); x <= region.getMaxX(); x++) { + for (int y = region.getMinY(); y <= region.getMaxY(); y++) { + for (int z = region.getMinZ(); z <= region.getMaxZ(); z++) { + String oraxenId = sampleOraxenId(); + Block block = world.getBlockAt(x, y, z); + OraxenBlocks.place(oraxenId, block.getLocation()); + count++; + } + } + } + + return count; + } + + private int fillAtLocationsSync(List locations) { + int count = 0; + for (Location loc : locations) { + if (loc.getWorld() == null) { + continue; + } + String oraxenId = sampleOraxenId(); + Block block = loc.getBlock(); + OraxenBlocks.place(oraxenId, block.getLocation()); + count++; + } + return count; + } + + private String sampleOraxenId() { + // Simple weighted random selection + double totalWeight = oraxenIds.values().stream().mapToDouble(Double::doubleValue).sum(); + double random = Math.random() * totalWeight; + + double currentWeight = 0; + for (Map.Entry entry : oraxenIds.entrySet()) { + currentWeight += entry.getValue(); + if (random <= currentWeight) { + return entry.getKey(); + } + } + + // Fallback to first entry + return oraxenIds.keySet().iterator().next(); + } +} diff --git a/src/main/java/com/loki/lomines/block/package-info.java b/src/main/java/com/loki/lomines/block/package-info.java new file mode 100644 index 0000000..e97daf2 --- /dev/null +++ b/src/main/java/com/loki/lomines/block/package-info.java @@ -0,0 +1,12 @@ +/** + * Block system for mine filling operations. + *

+ * This package contains the abstraction for setting blocks in mines: + *

    + *
  • {@code BlockSetter} - Abstract base class for block placement
  • + *
  • {@code BukkitBlockSetter} - Implementation for vanilla Bukkit blocks
  • + *
  • {@code OraxenBlockSetter} - Implementation for Oraxen custom blocks
  • + *
  • {@code ItemsAdderBlockSetter} - Implementation for ItemsAdder custom blocks
  • + *
+ */ +package com.loki.lomines.block; diff --git a/src/main/java/com/loki/lomines/command/MineCommands.java b/src/main/java/com/loki/lomines/command/MineCommands.java new file mode 100644 index 0000000..92d2b67 --- /dev/null +++ b/src/main/java/com/loki/lomines/command/MineCommands.java @@ -0,0 +1,341 @@ +package com.loki.lomines.command; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mine; +import com.loki.lomines.core.Mines; +import com.loki.lomines.data.ConfigParseException; +import com.loki.lomines.data.Leaderboard; +import com.loki.lomines.data.LeaderboardEntry; +import com.loki.lomines.data.PlayerStats; +import com.loki.lomines.data.StatsManager; +import com.loki.lomines.wand.GroupWandItem; +import dev.lolib.commands.annotation.Arg; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.commands.annotation.Command; +import dev.lolib.commands.annotation.Subcommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Main command class for mine management. + * Provides commands for creating, deleting, resetting, reloading, and listing mines. + */ +@Command(value = "lm", permission = "lomines.use") +public class MineCommands { + + private final LoMinesPlugin plugin; + private final Mines mines; + + public MineCommands(LoMinesPlugin plugin) { + this.plugin = plugin; + this.mines = plugin.getMines(); + } + + @Subcommand(value = "wand", permission = "lomines.admin.wand") + public void wand(CommandSender sender) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Только для игрока.")); + return; + } + player.getInventory().addItem(GroupWandItem.create(plugin)); + player.sendMessage(Component.text( + "Палочка группы: ЛКМ/ПКМ по блоку — углы, Shift+ПКМ — меню (9 шахт). Задайте /lm group <префикс>", + NamedTextColor.GREEN)); + } + + @Subcommand(value = "group", permission = "lomines.admin.wand") + public void group(CommandSender sender, @Arg("prefix") String prefix) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Префикс задаётся в игре.")); + return; + } + plugin.getGroupWandManager().getSession(player.getUniqueId()).setBaseName(prefix); + String p = prefix.trim(); + player.sendMessage(Component.text("Префикс: " + p + " → файлы " + p + "_1.yml … " + p + "_9.yml", NamedTextColor.AQUA)); + } + + /** + * Creates a new mine with the given name. + * Usage: /lm create + */ + @Subcommand(value = "create", permission = "lomines.admin.create") + public void create(CommandSender sender, @Arg("name") String name) { + try { + mines.create(name); + sender.sendMessage(Component.text("Successfully created mine: " + name)); + sender.sendMessage(Component.text( + "Edit selection & contents in plugins/LoMines/mines/" + name + ".yml — " + + "for shape-based ore (not full cuboid), paint mask.marker blocks, then: /lm maskscan " + name)); + } catch (IllegalArgumentException e) { + sender.sendMessage(Component.text("Mine already exists: " + name)); + } catch (IOException e) { + sender.sendMessage(Component.text("Failed to create mine: " + e.getMessage())); + plugin.loLogger().error("Failed to create mine " + name, e); + } + } + + /** + * Deletes an existing mine. + * Usage: /lm delete + */ + @Subcommand(value = "delete", permission = "lomines.admin.delete") + public void delete(CommandSender sender, @Arg("name") String name) { + try { + mines.delete(name); + sender.sendMessage(Component.text("Successfully deleted mine: " + name)); + } catch (IllegalArgumentException e) { + sender.sendMessage(Component.text("Mine not found: " + name)); + } catch (IOException e) { + sender.sendMessage(Component.text("Failed to delete mine: " + e.getMessage())); + plugin.loLogger().error("Failed to delete mine " + name, e); + } + } + + /** + * Resets a mine by refilling all its regions. + * Usage: /lm reset [silent] + */ + @Subcommand(value = "reset", permission = "lomines.admin.reset") + public void reset( + CommandSender sender, + @Arg("mine") String mineName, + @Arg(value = "silent", optional = true, defaultValue = "false") boolean silent + ) { + try { + Mine mine = mines.get(mineName); + mine.reset(silent); + sender.sendMessage(Component.text("Resetting mine: " + mineName)); + } catch (IllegalArgumentException e) { + sender.sendMessage(Component.text("Mine not found: " + mineName)); + } + } + + /** + * Reloads all mine configurations from disk. + * Usage: /lm reload + */ + @Subcommand(value = "reload", permission = "lomines.admin.reload") + public void reload(CommandSender sender) { + try { + // Stop all existing mines + mines.getAll().forEach(Mine::stop); + + // Reload all mines + mines.loadAll(); + + int count = mines.getAll().size(); + sender.sendMessage(Component.text("Successfully reloaded " + count + " mine(s)")); + } catch (IOException e) { + sender.sendMessage(Component.text("Failed to reload mines: " + e.getMessage())); + plugin.loLogger().error("Failed to reload mines", e); + } + } + + /** + * Lists all active mines. + * Usage: /lm list + */ + @Subcommand(value = "list", permission = "lomines.admin.list") + public void list(CommandSender sender) { + Collection allMines = mines.getAll(); + + if (allMines.isEmpty()) { + sender.sendMessage(Component.text("No mines found")); + return; + } + + sender.sendMessage(Component.text("=== Mines (" + allMines.size() + ") ===")); + + for (Mine mine : allMines) { + String status = formatMineStatus(mine); + sender.sendMessage(Component.text("- " + mine.getName() + " " + status)); + } + } + + /** + * Formats mine status information for display. + */ + private String formatMineStatus(Mine mine) { + int blocks = mine.getBlocks(); + int total = mine.getTotalVolume(); + double percent = mine.getPercentFilled(); + + return String.format("(%d/%d blocks, %.1f%%)", blocks, total, percent); + } + + /** + * Shows mining stats for self or another player. + * Usage: /lm stats [player] + */ + @Subcommand(value = "stats", permission = "lomines.stats") + public void stats(CommandSender sender, @Arg(value = "player", optional = true, defaultValue = "") String targetName) { + if (isStatsDisabled(sender)) { + return; + } + String name = targetName == null ? "" : targetName.trim(); + UUID uuid; + if (name.isEmpty()) { + if (!(sender instanceof Player self)) { + sender.sendMessage(Component.text("Укажите игрока: /lm stats <ник>", NamedTextColor.RED)); + return; + } + uuid = self.getUniqueId(); + } else { + if (!sender.hasPermission("lomines.stats.others")) { + sender.sendMessage(Component.text("Нет прав смотреть чужую статистику.", NamedTextColor.RED)); + return; + } + Player online = Bukkit.getPlayerExact(name); + if (online != null) { + uuid = online.getUniqueId(); + } else { + OfflinePlayer off = Bukkit.getOfflinePlayer(name); + uuid = off.getUniqueId(); + } + } + StatsManager sm = plugin.getStatsManager(); + PlayerStats ps = sm.getOrCreate(uuid); + String displayName = formatPlayerName(uuid); + sender.sendMessage(Component.text("=== Статистика: " + displayName + " ===", NamedTextColor.GOLD)); + sender.sendMessage(Component.text("Всего блоков: " + ps.getTotalBlocks(), NamedTextColor.WHITE)); + int pos = sm.getLeaderboard().getPosition(uuid); + if (pos > 0) { + sender.sendMessage(Component.text("Место в общем топе: #" + pos, NamedTextColor.GRAY)); + } + Map byMine = ps.getMineStatsSnapshot(); + if (byMine.isEmpty()) { + return; + } + sender.sendMessage(Component.text("По шахтам:", NamedTextColor.GRAY)); + byMine.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(12) + .forEach(e -> sender.sendMessage(Component.text( + " " + e.getKey() + ": " + e.getValue(), NamedTextColor.DARK_AQUA))); + } + + /** + * Leaderboard: global or per-mine. + * Usage: /lm top — глобально 10; /lm top 25 — глобально 25; /lm top <шахта> — по шахте; + * /lm top <шахта> <лимит> + */ + @Subcommand(value = "top", permission = "lomines.stats") + public void top( + CommandSender sender, + @Arg(value = "arg1", optional = true, defaultValue = "") String arg1, + @Arg(value = "arg2", optional = true, defaultValue = "") String arg2 + ) { + if (isStatsDisabled(sender)) { + return; + } + String a = arg1 == null ? "" : arg1.trim(); + String b = arg2 == null ? "" : arg2.trim(); + int limit; + String mineName; + if (a.isEmpty()) { + limit = clampLimit(10); + mineName = null; + } else if (b.isEmpty()) { + Integer asLimit = tryParsePositiveInt(a); + if (asLimit != null) { + limit = clampLimit(asLimit); + mineName = null; + } else { + limit = clampLimit(10); + mineName = a; + } + } else { + mineName = a; + Integer limParsed = tryParsePositiveInt(b); + limit = clampLimit(limParsed != null ? limParsed : 10); + } + Leaderboard lb = plugin.getStatsManager().getLeaderboard(); + if (mineName != null) { + var found = mines.find(mineName); + if (found.isEmpty()) { + sender.sendMessage(Component.text("Шахта не найдена: " + mineName, NamedTextColor.RED)); + return; + } + Mine m = found.get(); + String key = m.getName(); + List rows = lb.getTopByMine(key, limit); + sender.sendMessage(Component.text("=== Топ по шахте «" + key + "» ===", NamedTextColor.GOLD)); + sendTopRows(sender, rows); + return; + } + List rows = lb.getTopTotal(limit); + sender.sendMessage(Component.text("=== Общий топ ===", NamedTextColor.GOLD)); + sendTopRows(sender, rows); + } + + private static void sendTopRows(CommandSender sender, List rows) { + if (rows.isEmpty()) { + sender.sendMessage(Component.text("Пока нет данных.", NamedTextColor.GRAY)); + return; + } + for (int i = 0; i < rows.size(); i++) { + LeaderboardEntry e = rows.get(i); + String name = formatPlayerName(e.getPlayerId()); + sender.sendMessage(Component.text( + (i + 1) + ". " + name + " — " + e.getCount(), + NamedTextColor.WHITE)); + } + } + + private static String formatPlayerName(UUID id) { + String name = Bukkit.getOfflinePlayer(id).getName(); + return name != null ? name : id.toString(); + } + + private boolean isStatsDisabled(CommandSender sender) { + if (!plugin.getConfig().getBoolean("statistics-enabled", true)) { + sender.sendMessage(Component.text("Статистика отключена в config.yml.", NamedTextColor.RED)); + return true; + } + return false; + } + + private static int clampLimit(int limit) { + return Math.max(1, Math.min(50, limit)); + } + + private static Integer tryParsePositiveInt(String s) { + try { + int v = Integer.parseInt(s); + return v > 0 ? v : null; + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Scans selection for marker blocks ({@code mask.marker}, default pink concrete), saves mask positions and switches mine to mask fill mode. + * Run from the server main thread (scheduled automatically). + */ + @Subcommand(value = "maskscan", permission = "lomines.admin.maskscan") + public void maskScan(CommandSender sender, @Arg("mine") String name) { + Scheduler.get(plugin).run(() -> { + try { + int n = mines.scanAndSaveMask(name); + sender.sendMessage(Component.text( + "Mask mode: saved " + n + " cell(s) for mine '" + name + + "'. Reset will spawn ore only at those positions.")); + } catch (IllegalArgumentException e) { + sender.sendMessage(Component.text("Mine not found: " + name)); + } catch (IOException | ConfigParseException e) { + sender.sendMessage(Component.text("Mask scan failed: " + e.getMessage())); + plugin.loLogger().error("maskscan failed for " + name, e); + } + }); + } +} diff --git a/src/main/java/com/loki/lomines/command/package-info.java b/src/main/java/com/loki/lomines/command/package-info.java new file mode 100644 index 0000000..0480a05 --- /dev/null +++ b/src/main/java/com/loki/lomines/command/package-info.java @@ -0,0 +1,13 @@ +/** + * Command classes for mine management. + *

+ * This package contains command implementations using LoAPI's command framework: + *

    + *
  • {@code MineCommands} - Core mine management commands (create, delete, reset, reload, list)
  • + *
  • {@code MineUtilityCommands} - Utility commands (wand, teleport)
  • + *
  • {@code MineEditorCommands} - GUI editor commands
  • + *
  • {@code StatsCommands} - Statistics and leaderboard commands
  • + *
  • {@code WorldGuardCommands} - WorldGuard integration commands
  • + *
+ */ +package com.loki.lomines.command; diff --git a/src/main/java/com/loki/lomines/core/Mine.java b/src/main/java/com/loki/lomines/core/Mine.java new file mode 100644 index 0000000..75920b8 --- /dev/null +++ b/src/main/java/com/loki/lomines/core/Mine.java @@ -0,0 +1,218 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.block.BlockSetter; +import com.loki.lomines.data.FillMode; +import com.loki.lomines.data.MineConfig; +import com.loki.lomines.util.BlockKeys; +import com.loki.lomines.handler.ActionBarHandler; +import com.loki.lomines.handler.MineBlockHandler; +import com.loki.lomines.handler.MineResetHandler; +import com.loki.lomines.util.Cuboid; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents a single mine with its configuration and state. + * Thread-safe implementation using AtomicInteger for mutable state. + */ +public final class Mine { + + // Immutable configuration + private final String name; + private final List regions; + private final MineConfig config; + private final BlockSetter blockSetter; + private final LoMinesPlugin plugin; + private final int totalVolume; + /** Block keys for {@link FillMode#MASK}; empty when not mask mode. */ + private final Set maskBlockKeys; + + // Mutable state (thread-safe) + private final AtomicInteger blocks; + private final AtomicInteger ticks; + + // Handlers + private final MineResetHandler resetHandler; + private final MineBlockHandler blockHandler; + private final ActionBarHandler actionBarHandler; + + // Tasks + private ScheduledTask actionBarTask; + + /** + * Creates a new mine instance. + */ + public Mine(String name, MineConfig config, LoMinesPlugin plugin) { + this.name = name; + this.config = config; + this.plugin = plugin; + this.regions = parseRegions(config); + this.blockSetter = createBlockSetter(config, plugin); + + if (config.getFillMode() == FillMode.MASK) { + this.maskBlockKeys = new HashSet<>(); + for (Location loc : config.getMaskPositions()) { + maskBlockKeys.add(BlockKeys.key(loc)); + } + this.totalVolume = config.getMaskPositions().size(); + } else { + this.maskBlockKeys = Set.of(); + this.totalVolume = regions.stream().mapToInt(Cuboid::getVolume).sum(); + } + this.blocks = new AtomicInteger(Math.max(0, totalVolume)); + this.ticks = new AtomicInteger(0); + + this.resetHandler = new MineResetHandler(this, plugin); + this.blockHandler = new MineBlockHandler(this, plugin); + this.actionBarHandler = new ActionBarHandler(this); + } + + /** + * Starts periodic tasks for this mine. + */ + public void start() { + startActionBarTask(); + } + + /** + * Stops all tasks for this mine. + */ + public void stop() { + if (actionBarTask != null) { + actionBarTask.cancel(); + actionBarTask = null; + } + } + + /** + * Resets the mine by filling all regions with blocks. + */ + public void reset(boolean silent) { + resetHandler.reset(silent); + } + + /** + * Handles a block break event in this mine. + */ + public void onBlockBreak(Player player, Block block) { + blockHandler.handle(player, block); + } + + /** + * Checks if the given location is within any region of this mine. + */ + public boolean contains(Location location) { + return regions.stream().anyMatch(region -> region.contains(location)); + } + + /** + * Whether breaking this block counts toward mine progress ({@link FillMode#MASK} ignores blocks outside painted cells). + */ + public boolean appliesToBlock(Location location) { + if (!contains(location)) { + return false; + } + if (config.getFillMode() != FillMode.MASK) { + return true; + } + return maskBlockKeys.contains(BlockKeys.key(location)); + } + + /** + * Calculates the total volume of all regions in this mine. + */ + public int getTotalVolume() { + return totalVolume; + } + + /** + * Calculates the percentage of blocks remaining in the mine. + */ + public double getPercentFilled() { + if (totalVolume == 0) { + return 0.0; + } + return (double) blocks.get() / totalVolume * 100.0; + } + + /** + * Parses cuboid regions from selection points in the config. + * Selections are paired: 1-2, 3-4, 5-6, 7-8, 9-10. + */ + private List parseRegions(MineConfig config) { + List selections = config.getSelections(); + + if (selections.isEmpty()) { + throw new IllegalArgumentException("Mine must have at least one region (2 selection points)"); + } + + if (selections.size() % 2 != 0) { + throw new IllegalArgumentException("Selections must be in pairs (even number of points)"); + } + + List cuboids = new ArrayList<>(); + for (int i = 0; i < selections.size(); i += 2) { + Location loc1 = selections.get(i); + Location loc2 = selections.get(i + 1); + cuboids.add(new Cuboid(loc1, loc2)); + } + + return Collections.unmodifiableList(cuboids); + } + + /** + * Creates the appropriate BlockSetter based on block configuration. + */ + private BlockSetter createBlockSetter(MineConfig config, LoMinesPlugin plugin) { + Map blocks = config.getBlocks(); + + if (blocks.isEmpty()) { + throw new IllegalArgumentException("Mine must have at least one block type"); + } + + // Get the first block key to determine the setter type + String firstKey = blocks.keySet().iterator().next(); + + if (firstKey.startsWith("oraxen:")) { + return new com.loki.lomines.block.OraxenBlockSetter(blocks, plugin); + } else if (firstKey.startsWith("itemsadder:")) { + return new com.loki.lomines.block.ItemsAdderBlockSetter(blocks, plugin); + } else { + return new com.loki.lomines.block.BukkitBlockSetter(blocks, plugin); + } + } + + /** + * Starts the action bar update task. + */ + private void startActionBarTask() { + if (!config.isActionBarEnabled()) { + return; + } + + actionBarTask = Scheduler.get(plugin).runTimer(() -> { + actionBarHandler.sendToNearbyPlayers(); + }, 10L, 10L); + } + + public String getName() { return name; } + public MineConfig getConfig() { return config; } + public BlockSetter getBlockSetter() { return blockSetter; } + public List getRegions() { return regions; } + public AtomicInteger getBlocksAtomic() { return blocks; } + public int getBlocks() { return blocks.get(); } + public int getTicks() { return ticks.get(); } + public AtomicInteger getTicksAtomic() { return ticks; } +} diff --git a/src/main/java/com/loki/lomines/core/MineTicker.java b/src/main/java/com/loki/lomines/core/MineTicker.java new file mode 100644 index 0000000..eee06ad --- /dev/null +++ b/src/main/java/com/loki/lomines/core/MineTicker.java @@ -0,0 +1,60 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; + +/** + * Centralized ticker for all mines. + * Uses a single scheduled task to tick all mines every game tick. + * This is more efficient than having each mine run its own ticker. + */ +public final class MineTicker { + + private final Mines mines; + private final LoMinesPlugin plugin; + private ScheduledTask tickerTask; + + public MineTicker(Mines mines, LoMinesPlugin plugin) { + this.mines = mines; + this.plugin = plugin; + } + + /** + * Starts the centralized ticker. + * Ticks all mines every game tick (1L, 1L). + */ + public void start() { + if (tickerTask != null) { + return; // Already running + } + + tickerTask = Scheduler.get(plugin).runTimer(() -> { + for (Mine mine : mines.getAll()) { + tickMine(mine); + } + }, 1L, 1L); + } + + /** + * Stops the centralized ticker. + */ + public void stop() { + if (tickerTask != null) { + tickerTask.cancel(); + tickerTask = null; + } + } + + /** + * Ticks a single mine, checking if it needs to reset. + */ + private void tickMine(Mine mine) { + int currentTicks = mine.getTicksAtomic().incrementAndGet(); + + if (currentTicks >= mine.getConfig().getResetTicks()) { + mine.getTicksAtomic().set(0); + mine.reset(false); + } + } +} diff --git a/src/main/java/com/loki/lomines/core/Mines.java b/src/main/java/com/loki/lomines/core/Mines.java new file mode 100644 index 0000000..e3d6fb6 --- /dev/null +++ b/src/main/java/com/loki/lomines/core/Mines.java @@ -0,0 +1,247 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.data.ConfigLoader; +import com.loki.lomines.data.ConfigParseException; +import com.loki.lomines.data.MineConfig; +import com.loki.lomines.util.LocationParser; +import com.loki.lomines.util.MaskScanner; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +/** + * Registry for all active mines. + * Manages the lifecycle of mines including loading, creating, and deleting. + */ +public final class Mines { + + private final Map mines = new ConcurrentHashMap<>(); + private final LoMinesPlugin plugin; + private final Path minesFolder; + + public Mines(LoMinesPlugin plugin) { + this.plugin = plugin; + this.minesFolder = plugin.getDataFolder().toPath().resolve("mines"); + } + + /** + * Loads all mines from the mines/ folder. + * Clears and replaces any previously loaded mines (used for full reload). + */ + public void loadAll() throws IOException { + for (Mine m : new ArrayList<>(mines.values())) { + m.stop(); + } + mines.clear(); + + if (!Files.exists(minesFolder)) { + return; + } + + ConfigLoader loader = new ConfigLoader(); + + try (Stream paths = Files.list(minesFolder)) { + paths.filter(path -> path.toString().endsWith(".yml")) + .forEach(configFile -> { + try { + String fileName = configFile.getFileName().toString(); + String mineName = fileName.substring(0, fileName.length() - 4); + + MineConfig config = loader.load(configFile); + Mine mine = new Mine(mineName, config, plugin); + + mines.put(mineName.toLowerCase(), mine); + mine.start(); + + plugin.loLogger().info("Loaded mine: " + mineName); + } catch (IOException | ConfigParseException e) { + plugin.loLogger().error("Failed to load mine from " + configFile.getFileName() + ": " + e.getMessage()); + } + }); + } + } + + /** + * Creates a new mine with the given name and default cuboid corners. + */ + public void create(String name) throws IOException { + create(name, null, null); + } + + /** + * Creates a new mine; optional corners define {@code selection.1} and {@code selection.2} (same world required). + */ + public void create(String name, Location corner1, Location corner2) throws IOException { + String key = name.toLowerCase(); + if (mines.containsKey(key)) { + throw new IllegalArgumentException("Mine already exists: " + name); + } + if (corner1 != null && corner2 != null && !corner1.getWorld().equals(corner2.getWorld())) { + throw new IllegalArgumentException("Selection corners must be in the same world"); + } + + // Ensure mines folder exists + if (!Files.exists(minesFolder)) { + Files.createDirectories(minesFolder); + } + + // Create empty YAML configuration file + Path configFile = minesFolder.resolve(name + ".yml"); + YamlConfiguration yaml = new YamlConfiguration(); + + // Set default empty configuration + if (corner1 != null && corner2 != null) { + yaml.set("selection.1", LocationParser.format(corner1)); + yaml.set("selection.2", LocationParser.format(corner2)); + } else { + yaml.set("selection.1", "world;0;64;0;0;0"); + yaml.set("selection.2", "world;10;74;10;0;0"); + } + yaml.set("contents.stone", 100.0); + yaml.set("reset.ticks", 6000); + yaml.set("reset.percent", 10.0); + yaml.set("reset-on-percent", false); + yaml.set("actionbar.enabled", true); + yaml.set("actionbar.message", "&aMine: %mine% | Blocks: %blocks%/%total% (%percent%%)"); + yaml.set("actionbar.range", 50.0); + yaml.set("timer-format", "mm:ss"); + yaml.set("teleport-on-reset", false); + yaml.set("broadcast-reset", ""); + yaml.set("reset-commands", new ArrayList()); + yaml.set("random-rewards", new ArrayList>()); + yaml.set("fill-mode", "cuboid"); + yaml.set("mask.marker", "pink_concrete"); + yaml.set("mask.positions", new ArrayList()); + + yaml.save(configFile.toFile()); + + // Load the mine configuration + ConfigLoader loader = new ConfigLoader(); + MineConfig config; + try { + config = loader.load(configFile); + } catch (ConfigParseException e) { + throw new IOException("Failed to parse created mine configuration: " + e.getMessage(), e); + } + + // Create and register the mine + Mine mine = new Mine(name, config, plugin); + mines.put(key, mine); + mine.start(); + + plugin.loLogger().info("Created mine: " + name); + } + + /** + * Deletes a mine by name. + */ + public void delete(String name) throws IOException { + String key = name.toLowerCase(); + Mine mine = mines.remove(key); + + if (mine == null) { + throw new IllegalArgumentException("Mine not found: " + name); + } + + // Stop the mine + mine.stop(); + + // Delete configuration file + Path configFile = minesFolder.resolve(name + ".yml"); + if (Files.exists(configFile)) { + Files.delete(configFile); + } + } + + /** + * Gets a mine by name. + * @throws IllegalArgumentException if mine not found + */ + public Mine get(String name) { + Mine mine = mines.get(name.toLowerCase()); + if (mine == null) { + throw new IllegalArgumentException("Mine not found: " + name); + } + return mine; + } + + /** + * Finds a mine by name, returning Optional. + */ + public Optional find(String name) { + return Optional.ofNullable(mines.get(name.toLowerCase())); + } + + /** + * Returns all mines. + */ + public Collection getAll() { + return Collections.unmodifiableCollection(mines.values()); + } + + /** + * Finds a mine that contains the given location. + */ + public Optional findByLocation(Location location) { + return mines.values().stream() + .filter(mine -> mine.contains(location)) + .findFirst(); + } + + /** + * Scans cuboid regions for {@code mask.marker} blocks, writes {@code fill-mode: mask} and positions to disk, then reloads the mine. + * Must run on the main thread (world access). + */ + public int scanAndSaveMask(String mineName) throws IOException, ConfigParseException { + Mine mine = get(mineName); + Material marker; + try { + marker = Material.valueOf(mine.getConfig().getMaskMarkerMaterial().toUpperCase()); + } catch (IllegalArgumentException e) { + marker = Material.PINK_CONCRETE; + } + List found = MaskScanner.scan(mine.getRegions(), marker); + Path configFile = minesFolder.resolve(mineName + ".yml"); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configFile.toFile()); + yaml.set("fill-mode", "mask"); + yaml.set("mask.marker", mine.getConfig().getMaskMarkerMaterial()); + List lines = new ArrayList<>(); + for (Location loc : found) { + lines.add(LocationParser.format(loc)); + } + yaml.set("mask.positions", lines); + yaml.save(configFile.toFile()); + plugin.loLogger().info("Mask scan for '" + mineName + "': " + found.size() + " marker block(s)"); + reloadMine(mineName); + return found.size(); + } + + /** + * Reloads one mine from disk (stop, replace instance, start tasks). + */ + public void reloadMine(String name) throws IOException, ConfigParseException { + String key = name.toLowerCase(); + Mine old = mines.remove(key); + if (old != null) { + old.stop(); + } + Path configFile = minesFolder.resolve(name + ".yml"); + if (!Files.exists(configFile)) { + throw new IOException("Mine config not found: " + configFile); + } + ConfigLoader loader = new ConfigLoader(); + MineConfig config = loader.load(configFile); + Mine mine = new Mine(name, config, plugin); + mines.put(key, mine); + mine.start(); + plugin.loLogger().info("Reloaded mine: " + name); + } +} diff --git a/src/main/java/com/loki/lomines/core/package-info.java b/src/main/java/com/loki/lomines/core/package-info.java new file mode 100644 index 0000000..b007250 --- /dev/null +++ b/src/main/java/com/loki/lomines/core/package-info.java @@ -0,0 +1,11 @@ +/** + * Core components of the LoMines plugin. + *

+ * This package contains the main plugin class and core system classes: + *

    + *
  • {@code LoMinesPlugin} - Main plugin class extending LoPlugin
  • + *
  • {@code Mines} - Registry for managing all active mines
  • + *
  • {@code Mine} - Representation of a single mine with its configuration and state
  • + *
+ */ +package com.loki.lomines.core; diff --git a/src/main/java/com/loki/lomines/data/ConfigLoader.java b/src/main/java/com/loki/lomines/data/ConfigLoader.java new file mode 100644 index 0000000..2a9f254 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/ConfigLoader.java @@ -0,0 +1,712 @@ +package com.loki.lomines.data; + +import com.loki.lomines.util.LocationParser; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Loads MineConfig objects from YAML configuration files. + * Validates all fields and throws ConfigParseException for invalid data. + */ +public final class ConfigLoader { + + public ConfigLoader() { + } + + /** + * Loads a MineConfig from a YAML file. + * + * @param configFile the path to the YAML configuration file + * @return the loaded MineConfig object + * @throws IOException if the file cannot be read + * @throws ConfigParseException if the configuration is invalid + */ + public MineConfig load(Path configFile) throws IOException, ConfigParseException { + if (configFile == null) { + throw new ConfigParseException("Config file path cannot be null"); + } + + if (!configFile.toFile().exists()) { + throw new IOException("Config file does not exist: " + configFile); + } + + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configFile.toFile()); + + FillMode fillMode = parseFillMode(yaml); + return MineConfig.builder() + .selections(parseSelections(yaml)) + .blocks(parseBlocks(yaml)) + .fillMode(fillMode) + .maskMarkerMaterial(parseMaskMarker(yaml)) + .maskPositions(parseMaskPositions(yaml)) + .rewards(parseRewards(yaml)) + .resetTicks(yaml.getInt("reset.ticks", 6000)) + .resetPercent(yaml.getDouble("reset.percent", 10.0)) + .resetOnPercentEnabled(yaml.getBoolean("reset-on-percent", false)) + .actionBarEnabled(yaml.getBoolean("actionbar.enabled", true)) + .actionBarMessage(yaml.getString("actionbar.message", "")) + .actionBarRange(yaml.getDouble("actionbar.range", 50.0)) + .timerFormat(yaml.getString("timer-format", "mm:ss")) + .teleportOnReset(yaml.getBoolean("teleport-on-reset", false)) + .teleportLocation(parseTeleportLocation(yaml)) + .resetCommands(yaml.getStringList("reset-commands")) + .broadcastReset(yaml.getString("broadcast-reset", "")) + .build(); + } + + /** + * Parses selection coordinates from the YAML configuration. + * Reads selection.1 through selection.10 fields. + * + * @param yaml the YAML configuration + * @return list of parsed Location objects + * @throws ConfigParseException if any selection coordinate is invalid + */ + private List parseSelections(YamlConfiguration yaml) throws ConfigParseException { + List selections = new ArrayList<>(); + + for (int i = 1; i <= 10; i++) { + String key = "selection." + i; + if (yaml.contains(key)) { + String locationString = yaml.getString(key); + if (locationString == null || locationString.trim().isEmpty()) { + throw new ConfigParseException( + "Selection coordinate at '" + key + "' is empty" + ); + } + + try { + Location location = LocationParser.parse(locationString); + selections.add(location); + } catch (ConfigParseException e) { + throw new ConfigParseException( + "Invalid selection coordinate at '" + key + "': " + e.getMessage(), + e + ); + } + } + } + + if (selections.isEmpty()) { + throw new ConfigParseException( + "No selection coordinates found. At least one selection pair (2 points) is required" + ); + } + + if (selections.size() % 2 != 0) { + throw new ConfigParseException( + "Invalid number of selection coordinates: " + selections.size() + + ". Selections must be in pairs (even number)" + ); + } + + return selections; + } + + /** + * Parses block contents from the YAML configuration. + * Supports vanilla blocks, Oraxen blocks (oraxen: prefix), and ItemsAdder blocks (itemsadder: prefix). + * + * @param yaml the YAML configuration + * @return map of block string key to weight + * @throws ConfigParseException if block configuration is invalid + */ + private Map parseBlocks(YamlConfiguration yaml) throws ConfigParseException { + ConfigurationSection contentsSection = yaml.getConfigurationSection("contents"); + + if (contentsSection == null) { + throw new ConfigParseException( + "Missing required 'contents' section in configuration" + ); + } + + Map blocks = new HashMap<>(); + + for (String key : contentsSection.getKeys(false)) { + Object value = contentsSection.get(key); + + double weight; + try { + if (value instanceof Number) { + weight = ((Number) value).doubleValue(); + } else if (value instanceof String) { + weight = Double.parseDouble((String) value); + } else { + throw new ConfigParseException( + "Invalid weight type for block '" + key + "': expected number, got " + + (value != null ? value.getClass().getSimpleName() : "null") + ); + } + } catch (NumberFormatException e) { + throw new ConfigParseException( + "Invalid weight value for block '" + key + "': " + value, + e + ); + } + + if (weight <= 0) { + throw new ConfigParseException( + "Block weight must be positive for '" + key + "', got: " + weight + ); + } + + // Validate block key format + if (key.startsWith("oraxen:") || key.startsWith("itemsadder:")) { + // Custom blocks - just store the string key + blocks.put(key, weight); + } else { + // Vanilla block - validate material exists + try { + Material.valueOf(key.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ConfigParseException( + "Unknown material: '" + key + "'. Must be a valid Minecraft material name", + e + ); + } + + // Store the lowercase material name as key + blocks.put(key.toLowerCase(), weight); + } + } + + if (blocks.isEmpty()) { + throw new ConfigParseException( + "No blocks defined in 'contents' section. At least one block is required" + ); + } + + return blocks; + } + + private FillMode parseFillMode(YamlConfiguration yaml) { + String raw = yaml.getString("fill-mode", "cuboid"); + if (raw == null || raw.isBlank()) { + return FillMode.CUBOID; + } + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "mask" -> FillMode.MASK; + default -> FillMode.CUBOID; + }; + } + + private String parseMaskMarker(YamlConfiguration yaml) throws ConfigParseException { + String marker = yaml.getString("mask.marker", "pink_concrete"); + if (marker == null || marker.isBlank()) { + return "pink_concrete"; + } + try { + Material.valueOf(marker.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ConfigParseException("Invalid mask.marker material: '" + marker + "'", e); + } + return marker.trim().toLowerCase(Locale.ROOT); + } + + private List parseMaskPositions(YamlConfiguration yaml) throws ConfigParseException { + List rawList = yaml.getStringList("mask.positions"); + if (rawList == null || rawList.isEmpty()) { + return List.of(); + } + List out = new ArrayList<>(); + for (int i = 0; i < rawList.size(); i++) { + String line = rawList.get(i); + if (line == null || line.isBlank()) { + continue; + } + try { + out.add(LocationParser.parse(line.trim())); + } catch (ConfigParseException e) { + throw new ConfigParseException("Invalid mask.positions entry at index " + i + ": " + e.getMessage(), e); + } + } + return out; + } + + /** + * Parses rewards from the YAML configuration. + * Reads the random-rewards list and creates Reward objects. + * + * @param yaml the YAML configuration + * @return list of parsed Reward objects + * @throws ConfigParseException if reward configuration is invalid + */ + private List parseRewards(YamlConfiguration yaml) throws ConfigParseException { + List rewards = new ArrayList<>(); + + if (!yaml.contains("random-rewards")) { + return rewards; // Rewards are optional + } + + List rewardsList = yaml.getList("random-rewards"); + if (rewardsList == null) { + return rewards; + } + + for (int i = 0; i < rewardsList.size(); i++) { + Object rewardObj = rewardsList.get(i); + + if (!(rewardObj instanceof Map)) { + throw new ConfigParseException( + "Invalid reward at index " + i + ": expected map, got " + + (rewardObj != null ? rewardObj.getClass().getSimpleName() : "null") + ); + } + + @SuppressWarnings("unchecked") + Map rewardMap = (Map) rewardObj; + + try { + Reward reward = parseReward(rewardMap, i); + rewards.add(reward); + } catch (ConfigParseException e) { + throw new ConfigParseException( + "Error parsing reward at index " + i + ": " + e.getMessage(), + e + ); + } + } + + return rewards; + } + + /** + * Parses a single reward from a map. + * + * @param rewardMap the reward configuration map + * @param index the index of the reward (for error messages) + * @return the parsed Reward object + * @throws ConfigParseException if the reward configuration is invalid + */ + private Reward parseReward(Map rewardMap, int index) throws ConfigParseException { + // Parse chance + double chance = parseRewardChance(rewardMap, index); + + // Parse prevent-drops + boolean preventDrops = false; + if (rewardMap.containsKey("prevent-drops")) { + Object preventDropsObj = rewardMap.get("prevent-drops"); + if (preventDropsObj instanceof Boolean) { + preventDrops = (Boolean) preventDropsObj; + } else { + throw new ConfigParseException( + "Invalid 'prevent-drops' value: expected boolean, got " + + (preventDropsObj != null ? preventDropsObj.getClass().getSimpleName() : "null") + ); + } + } + + // Parse blocks (materials) + List materials = parseRewardMaterials(rewardMap, index); + + // Parse items + List items = parseRewardItems(rewardMap, index); + + // Parse commands + List commands = parseRewardCommands(rewardMap, index); + + return new Reward(materials, chance, items, commands, preventDrops); + } + + /** + * Parses the chance field from a reward map. + */ + private double parseRewardChance(Map rewardMap, int index) throws ConfigParseException { + if (!rewardMap.containsKey("chance")) { + throw new ConfigParseException("Missing required 'chance' field in reward"); + } + + Object chanceObj = rewardMap.get("chance"); + double chance; + + try { + if (chanceObj instanceof Number) { + chance = ((Number) chanceObj).doubleValue(); + } else if (chanceObj instanceof String) { + chance = Double.parseDouble((String) chanceObj); + } else { + throw new ConfigParseException( + "Invalid 'chance' type: expected number, got " + + (chanceObj != null ? chanceObj.getClass().getSimpleName() : "null") + ); + } + } catch (NumberFormatException e) { + throw new ConfigParseException( + "Invalid 'chance' value: " + chanceObj, + e + ); + } + + if (chance < 0 || chance > 100) { + throw new ConfigParseException( + "Reward chance must be between 0 and 100, got: " + chance + ); + } + + return chance; + } + + /** + * Parses the blocks (materials) field from a reward map. + */ + private List parseRewardMaterials(Map rewardMap, int index) + throws ConfigParseException { + if (!rewardMap.containsKey("blocks")) { + throw new ConfigParseException("Missing required 'blocks' field in reward"); + } + + Object blocksObj = rewardMap.get("blocks"); + if (!(blocksObj instanceof List)) { + throw new ConfigParseException( + "Invalid 'blocks' type: expected list, got " + + (blocksObj != null ? blocksObj.getClass().getSimpleName() : "null") + ); + } + + @SuppressWarnings("unchecked") + List blocksList = (List) blocksObj; + + if (blocksList.isEmpty()) { + throw new ConfigParseException("Reward 'blocks' list cannot be empty"); + } + + List materials = new ArrayList<>(); + for (String blockName : blocksList) { + if (blockName == null || blockName.trim().isEmpty()) { + throw new ConfigParseException("Block name cannot be null or empty"); + } + + try { + Material material = Material.valueOf(blockName.toUpperCase()); + materials.add(material); + } catch (IllegalArgumentException e) { + throw new ConfigParseException( + "Unknown material in reward blocks: '" + blockName + "'", + e + ); + } + } + + return materials; + } + + /** + * Parses the items field from a reward map. + */ + private List parseRewardItems(Map rewardMap, int index) + throws ConfigParseException { + List items = new ArrayList<>(); + + if (!rewardMap.containsKey("items")) { + return items; // Items are optional + } + + Object itemsObj = rewardMap.get("items"); + if (!(itemsObj instanceof List)) { + throw new ConfigParseException( + "Invalid 'items' type: expected list, got " + + (itemsObj != null ? itemsObj.getClass().getSimpleName() : "null") + ); + } + + List itemsList = (List) itemsObj; + + for (int i = 0; i < itemsList.size(); i++) { + Object itemObj = itemsList.get(i); + + if (!(itemObj instanceof Map)) { + throw new ConfigParseException( + "Invalid item at index " + i + ": expected map, got " + + (itemObj != null ? itemObj.getClass().getSimpleName() : "null") + ); + } + + @SuppressWarnings("unchecked") + Map itemMap = (Map) itemObj; + + ItemStack item = parseRewardItem(itemMap, i); + items.add(item); + } + + return items; + } + + /** + * Parses a single item from a map. + */ + private ItemStack parseRewardItem(Map itemMap, int index) + throws ConfigParseException { + // Parse type (material) + if (!itemMap.containsKey("type")) { + throw new ConfigParseException("Missing required 'type' field in item at index " + index); + } + + String typeName = String.valueOf(itemMap.get("type")); + Material material; + try { + material = Material.valueOf(typeName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ConfigParseException( + "Unknown material in item type: '" + typeName + "'", + e + ); + } + + // Parse amount + int amount = 1; + if (itemMap.containsKey("amount")) { + Object amountObj = itemMap.get("amount"); + try { + if (amountObj instanceof Number) { + amount = ((Number) amountObj).intValue(); + } else if (amountObj instanceof String) { + amount = Integer.parseInt((String) amountObj); + } else { + throw new ConfigParseException( + "Invalid 'amount' type: expected number, got " + + (amountObj != null ? amountObj.getClass().getSimpleName() : "null") + ); + } + } catch (NumberFormatException e) { + throw new ConfigParseException( + "Invalid 'amount' value: " + amountObj, + e + ); + } + + if (amount <= 0) { + throw new ConfigParseException( + "Item amount must be positive, got: " + amount + ); + } + } + + ItemStack item = new ItemStack(material, amount); + + // Note: name and lore parsing would require ItemMeta manipulation + // For now, we create a basic ItemStack + // Full implementation with name/lore can be added later if needed + + return item; + } + + /** + * Parses the commands field from a reward map. + */ + private List parseRewardCommands(Map rewardMap, int index) + throws ConfigParseException { + List commands = new ArrayList<>(); + + if (!rewardMap.containsKey("commands")) { + return commands; // Commands are optional + } + + Object commandsObj = rewardMap.get("commands"); + if (!(commandsObj instanceof List)) { + throw new ConfigParseException( + "Invalid 'commands' type: expected list, got " + + (commandsObj != null ? commandsObj.getClass().getSimpleName() : "null") + ); + } + + @SuppressWarnings("unchecked") + List commandsList = (List) commandsObj; + + for (String command : commandsList) { + if (command == null || command.trim().isEmpty()) { + throw new ConfigParseException("Command cannot be null or empty"); + } + commands.add(command); + } + + return commands; + } + + /** + * Parses the teleport location from the YAML configuration. + * + * @param yaml the YAML configuration + * @return the parsed Location, or null if not configured + * @throws ConfigParseException if the teleport location is invalid + */ + private Location parseTeleportLocation(YamlConfiguration yaml) throws ConfigParseException { + if (!yaml.contains("teleport-location")) { + return null; // Teleport location is optional + } + + String locationString = yaml.getString("teleport-location"); + if (locationString == null || locationString.trim().isEmpty()) { + return null; + } + + try { + return LocationParser.parse(locationString); + } catch (ConfigParseException e) { + throw new ConfigParseException( + "Invalid teleport location: " + e.getMessage(), + e + ); + } + } + + + /** + * Saves a MineConfig to a YAML file. + * + * @param config the MineConfig to save + * @param configFile the path to the YAML configuration file + * @throws IOException if the file cannot be written + * @throws IllegalArgumentException if config or configFile is null + */ + public void save(MineConfig config, Path configFile) throws IOException { + if (config == null) { + throw new IllegalArgumentException("Config cannot be null"); + } + if (configFile == null) { + throw new IllegalArgumentException("Config file path cannot be null"); + } + + YamlConfiguration yaml = new YamlConfiguration(); + + // Serialize all sections + saveSelections(yaml, config.getSelections()); + saveBlocks(yaml, config.getBlocks()); + yaml.set("fill-mode", config.getFillMode().name().toLowerCase(Locale.ROOT)); + yaml.set("mask.marker", config.getMaskMarkerMaterial()); + saveMaskPositions(yaml, config.getMaskPositions()); + saveRewards(yaml, config.getRewards()); + + // Save reset settings + yaml.set("reset.ticks", config.getResetTicks()); + yaml.set("reset.percent", config.getResetPercent()); + yaml.set("reset-on-percent", config.isResetOnPercentEnabled()); + yaml.set("reset-commands", config.getResetCommands()); + yaml.set("broadcast-reset", config.getBroadcastReset()); + + // Save teleport settings + yaml.set("teleport-on-reset", config.isTeleportOnReset()); + if (config.getTeleportLocation() != null) { + yaml.set("teleport-location", LocationParser.format(config.getTeleportLocation())); + } + + // Save action bar settings + yaml.set("actionbar.enabled", config.isActionBarEnabled()); + yaml.set("actionbar.message", config.getActionBarMessage()); + yaml.set("actionbar.range", config.getActionBarRange()); + + // Save timer format + yaml.set("timer-format", config.getTimerFormat()); + + // Save to file + yaml.save(configFile.toFile()); + } + + /** + * Saves selection coordinates to the YAML configuration. + * Writes selection.1 through selection.N fields using LocationParser.format(). + * + * @param yaml the YAML configuration + * @param selections the list of selection locations + */ + private void saveSelections(YamlConfiguration yaml, List selections) { + for (int i = 0; i < selections.size(); i++) { + Location location = selections.get(i); + String key = "selection." + (i + 1); + yaml.set(key, LocationParser.format(location)); + } + } + + /** + * Saves block contents to the YAML configuration. + * Writes the contents section with block keys and weights. + * + * @param yaml the YAML configuration + * @param blocks the map of block string key to weight + */ + private void saveBlocks(YamlConfiguration yaml, Map blocks) { + for (Map.Entry entry : blocks.entrySet()) { + String blockKey = entry.getKey(); + Double weight = entry.getValue(); + + // Store the block key as-is (already in correct format) + yaml.set("contents." + blockKey, weight); + } + } + + private void saveMaskPositions(YamlConfiguration yaml, List maskPositions) { + if (maskPositions == null || maskPositions.isEmpty()) { + yaml.set("mask.positions", new ArrayList()); + return; + } + List lines = new ArrayList<>(); + for (Location loc : maskPositions) { + lines.add(LocationParser.format(loc)); + } + yaml.set("mask.positions", lines); + } + + /** + * Saves rewards to the YAML configuration. + * Writes the random-rewards list with all reward properties. + * + * @param yaml the YAML configuration + * @param rewards the list of rewards + */ + private void saveRewards(YamlConfiguration yaml, List rewards) { + if (rewards.isEmpty()) { + return; // Don't write empty rewards section + } + + List> rewardsList = new ArrayList<>(); + + for (Reward reward : rewards) { + Map rewardMap = new HashMap<>(); + + // Save chance + rewardMap.put("chance", reward.getChance()); + + // Save prevent-drops + rewardMap.put("prevent-drops", reward.isPreventDrops()); + + // Save blocks (materials) + List blockNames = new ArrayList<>(); + for (Material material : reward.getMaterials()) { + blockNames.add(material.name().toLowerCase()); + } + rewardMap.put("blocks", blockNames); + + // Save items + if (!reward.getItems().isEmpty()) { + List> itemsList = new ArrayList<>(); + for (ItemStack item : reward.getItems()) { + Map itemMap = new HashMap<>(); + itemMap.put("type", item.getType().name().toLowerCase()); + itemMap.put("amount", item.getAmount()); + itemsList.add(itemMap); + } + rewardMap.put("items", itemsList); + } + + // Save commands + if (!reward.getCommands().isEmpty()) { + rewardMap.put("commands", new ArrayList<>(reward.getCommands())); + } + + rewardsList.add(rewardMap); + } + + yaml.set("random-rewards", rewardsList); + } + +} diff --git a/src/main/java/com/loki/lomines/data/ConfigParseException.java b/src/main/java/com/loki/lomines/data/ConfigParseException.java new file mode 100644 index 0000000..0f69b26 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/ConfigParseException.java @@ -0,0 +1,16 @@ +package com.loki.lomines.data; + +/** + * Exception thrown when configuration parsing fails. + * Provides descriptive error messages for invalid configuration data. + */ +public final class ConfigParseException extends Exception { + + public ConfigParseException(String message) { + super(message); + } + + public ConfigParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/loki/lomines/data/FillMode.java b/src/main/java/com/loki/lomines/data/FillMode.java new file mode 100644 index 0000000..9aa96f8 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/FillMode.java @@ -0,0 +1,13 @@ +package com.loki.lomines.data; + +/** + * How mine contents are placed on reset. + *
    + *
  • {@link #CUBOID} — fill entire selection box (classic)
  • + *
  • {@link #MASK} — only at stored block positions (painted with a marker block, then scanned)
  • + *
+ */ +public enum FillMode { + CUBOID, + MASK +} diff --git a/src/main/java/com/loki/lomines/data/Leaderboard.java b/src/main/java/com/loki/lomines/data/Leaderboard.java new file mode 100644 index 0000000..8c7ae3c --- /dev/null +++ b/src/main/java/com/loki/lomines/data/Leaderboard.java @@ -0,0 +1,105 @@ +package com.loki.lomines.data; + +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Manages leaderboard functionality with caching. + * Provides methods to retrieve top players by total blocks or by specific mine. + * + * Thread-safety: Uses volatile cache and synchronized blocks for thread-safe operations. + * Cache is invalidated when player statistics change. + */ +public final class Leaderboard { + + private final StatsManager statsManager; + + // Cache for getTopTotal - volatile for visibility across threads + private volatile List cachedTopTotal = null; + private final Object cacheLock = new Object(); + + /** + * Creates a new Leaderboard instance. + * + * @param statsManager The stats manager to retrieve player statistics from + */ + public Leaderboard(StatsManager statsManager) { + this.statsManager = statsManager; + } + + /** + * Invalidates the cached top total leaderboard. + * Should be called when player statistics change. + */ + public void invalidateCache() { + synchronized (cacheLock) { + cachedTopTotal = null; + } + } + + /** + * Returns the top players by total blocks mined across all mines. + * Results are cached and reused until invalidateCache() is called. + * + * @param limit Maximum number of entries to return + * @return List of leaderboard entries sorted by descending block count + */ + public List getTopTotal(int limit) { + // Fast path: check cache without locking + if (cachedTopTotal != null) { + return cachedTopTotal.subList(0, Math.min(limit, cachedTopTotal.size())); + } + + // Slow path: build cache with double-checked locking + synchronized (cacheLock) { + // Double-check: another thread might have built the cache + if (cachedTopTotal != null) { + return cachedTopTotal.subList(0, Math.min(limit, cachedTopTotal.size())); + } + + // Build the cache + cachedTopTotal = statsManager.getAllStats().stream() + .filter(stats -> stats.getTotalBlocks() > 0) + .sorted(Comparator.comparingLong(PlayerStats::getTotalBlocks).reversed()) + .map(stats -> new LeaderboardEntry(stats.getPlayerId(), stats.getTotalBlocks())) + .collect(Collectors.toList()); + + return cachedTopTotal.subList(0, Math.min(limit, cachedTopTotal.size())); + } + } + + /** + * Returns the top players for a specific mine. + * This method does not use caching as mine-specific leaderboards are less frequently accessed. + * + * @param mineName The name of the mine + * @param limit Maximum number of entries to return + * @return List of leaderboard entries sorted by descending block count for the specified mine + */ + public List getTopByMine(String mineName, int limit) { + return statsManager.getAllStats().stream() + .map(stats -> new LeaderboardEntry(stats.getPlayerId(), stats.getMineBlocks(mineName))) + .filter(entry -> entry.getCount() > 0) + .sorted(Comparator.comparingLong(LeaderboardEntry::getCount).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } + + /** + * Returns the position of a player in the total leaderboard. + * + * @param playerId The UUID of the player + * @return The 1-based position of the player, or -1 if the player is not in the leaderboard + */ + public int getPosition(UUID playerId) { + List top = getTopTotal(Integer.MAX_VALUE); + for (int i = 0; i < top.size(); i++) { + if (top.get(i).getPlayerId().equals(playerId)) { + return i + 1; // 1-based position + } + } + return -1; + } +} diff --git a/src/main/java/com/loki/lomines/data/LeaderboardEntry.java b/src/main/java/com/loki/lomines/data/LeaderboardEntry.java new file mode 100644 index 0000000..02fa6b4 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/LeaderboardEntry.java @@ -0,0 +1,45 @@ +package com.loki.lomines.data; + +import java.util.Objects; +import java.util.UUID; + +/** + * Immutable data class representing a single entry in the leaderboard. + * Contains a player's UUID and their block count. + */ +public final class LeaderboardEntry { + + private final UUID playerId; + private final long count; + + public LeaderboardEntry(UUID playerId, long count) { + this.playerId = Objects.requireNonNull(playerId, "playerId cannot be null"); + this.count = count; + } + + public UUID getPlayerId() { + return playerId; + } + + public long getCount() { + return count; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LeaderboardEntry that = (LeaderboardEntry) o; + return count == that.count && playerId.equals(that.playerId); + } + + @Override + public int hashCode() { + return Objects.hash(playerId, count); + } + + @Override + public String toString() { + return "LeaderboardEntry{playerId=" + playerId + ", count=" + count + "}"; + } +} diff --git a/src/main/java/com/loki/lomines/data/MineConfig.java b/src/main/java/com/loki/lomines/data/MineConfig.java new file mode 100644 index 0000000..dad48be --- /dev/null +++ b/src/main/java/com/loki/lomines/data/MineConfig.java @@ -0,0 +1,291 @@ +package com.loki.lomines.data; + +import org.bukkit.Location; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable configuration data model for a mine. + * All fields are final to ensure immutability. + * Use the Builder pattern to create instances. + */ +public final class MineConfig { + + // Regions + private final List selections; + + // Blocks + private final Map blocks; + + /** How blocks are placed on reset. */ + private final FillMode fillMode; + /** Vanilla material name for {@link FillMode#MASK} scan (e.g. pink_concrete). */ + private final String maskMarkerMaterial; + /** Block centers for mask mode; ignored when {@link #fillMode} is {@link FillMode#CUBOID}. */ + private final List maskPositions; + + // Rewards + private final List rewards; + + // Reset + private final int resetTicks; + private final double resetPercent; + private final boolean resetOnPercentEnabled; + private final List resetCommands; + private final String broadcastReset; + + // Teleportation + private final boolean teleportOnReset; + private final Location teleportLocation; + + // Action Bar + private final boolean actionBarEnabled; + private final String actionBarMessage; + private final double actionBarRange; + + // Timer + private final String timerFormat; + + private MineConfig(Builder builder) { + this.selections = List.copyOf(builder.selections); + this.blocks = Map.copyOf(builder.blocks); + this.fillMode = builder.fillMode; + this.maskMarkerMaterial = builder.maskMarkerMaterial; + this.maskPositions = List.copyOf(builder.maskPositions); + this.rewards = List.copyOf(builder.rewards); + this.resetTicks = builder.resetTicks; + this.resetPercent = builder.resetPercent; + this.resetOnPercentEnabled = builder.resetOnPercentEnabled; + this.resetCommands = List.copyOf(builder.resetCommands); + this.broadcastReset = builder.broadcastReset; + this.teleportOnReset = builder.teleportOnReset; + this.teleportLocation = builder.teleportLocation; + this.actionBarEnabled = builder.actionBarEnabled; + this.actionBarMessage = builder.actionBarMessage; + this.actionBarRange = builder.actionBarRange; + this.timerFormat = builder.timerFormat; + } + + public static Builder builder() { + return new Builder(); + } + + // Getters + public List getSelections() { + return selections; + } + + public Map getBlocks() { + return blocks; + } + + public FillMode getFillMode() { + return fillMode; + } + + /** + * Material name for scanning marker blocks (vanilla), e.g. {@code pink_concrete}. + */ + public String getMaskMarkerMaterial() { + return maskMarkerMaterial; + } + + public List getMaskPositions() { + return maskPositions; + } + + public List getRewards() { + return rewards; + } + + public int getResetTicks() { + return resetTicks; + } + + public double getResetPercent() { + return resetPercent; + } + + public boolean isResetOnPercentEnabled() { + return resetOnPercentEnabled; + } + + public List getResetCommands() { + return resetCommands; + } + + public String getBroadcastReset() { + return broadcastReset; + } + + public boolean isTeleportOnReset() { + return teleportOnReset; + } + + public Location getTeleportLocation() { + return teleportLocation; + } + + public boolean isActionBarEnabled() { + return actionBarEnabled; + } + + public String getActionBarMessage() { + return actionBarMessage; + } + + public double getActionBarRange() { + return actionBarRange; + } + + public String getTimerFormat() { + return timerFormat; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MineConfig that = (MineConfig) o; + return resetTicks == that.resetTicks && + Double.compare(that.resetPercent, resetPercent) == 0 && + resetOnPercentEnabled == that.resetOnPercentEnabled && + teleportOnReset == that.teleportOnReset && + actionBarEnabled == that.actionBarEnabled && + Double.compare(that.actionBarRange, actionBarRange) == 0 && + Objects.equals(selections, that.selections) && + Objects.equals(blocks, that.blocks) && + fillMode == that.fillMode && + Objects.equals(maskMarkerMaterial, that.maskMarkerMaterial) && + Objects.equals(maskPositions, that.maskPositions) && + Objects.equals(rewards, that.rewards) && + Objects.equals(resetCommands, that.resetCommands) && + Objects.equals(broadcastReset, that.broadcastReset) && + Objects.equals(teleportLocation, that.teleportLocation) && + Objects.equals(actionBarMessage, that.actionBarMessage) && + Objects.equals(timerFormat, that.timerFormat); + } + + @Override + public int hashCode() { + return Objects.hash(selections, blocks, fillMode, maskMarkerMaterial, maskPositions, rewards, resetTicks, resetPercent, + resetOnPercentEnabled, resetCommands, broadcastReset, teleportOnReset, + teleportLocation, actionBarEnabled, actionBarMessage, actionBarRange, timerFormat); + } + + public static final class Builder { + private List selections = new ArrayList<>(); + private Map blocks = new HashMap<>(); + private FillMode fillMode = FillMode.CUBOID; + private String maskMarkerMaterial = "pink_concrete"; + private List maskPositions = new ArrayList<>(); + private List rewards = new ArrayList<>(); + private int resetTicks = 6000; + private double resetPercent = 10.0; + private boolean resetOnPercentEnabled = false; + private List resetCommands = new ArrayList<>(); + private String broadcastReset = ""; + private boolean teleportOnReset = false; + private Location teleportLocation = null; + private boolean actionBarEnabled = true; + private String actionBarMessage = ""; + private double actionBarRange = 50.0; + private String timerFormat = "mm:ss"; + + private Builder() { + } + + public Builder selections(List selections) { + this.selections = new ArrayList<>(selections); + return this; + } + + public Builder blocks(Map blocks) { + this.blocks = new HashMap<>(blocks); + return this; + } + + public Builder fillMode(FillMode fillMode) { + this.fillMode = fillMode != null ? fillMode : FillMode.CUBOID; + return this; + } + + public Builder maskMarkerMaterial(String maskMarkerMaterial) { + this.maskMarkerMaterial = maskMarkerMaterial != null ? maskMarkerMaterial : "pink_concrete"; + return this; + } + + public Builder maskPositions(List maskPositions) { + this.maskPositions = maskPositions != null ? new ArrayList<>(maskPositions) : new ArrayList<>(); + return this; + } + + public Builder rewards(List rewards) { + this.rewards = new ArrayList<>(rewards); + return this; + } + + public Builder resetTicks(int resetTicks) { + this.resetTicks = resetTicks; + return this; + } + + public Builder resetPercent(double resetPercent) { + this.resetPercent = resetPercent; + return this; + } + + public Builder resetOnPercentEnabled(boolean resetOnPercentEnabled) { + this.resetOnPercentEnabled = resetOnPercentEnabled; + return this; + } + + public Builder resetCommands(List resetCommands) { + this.resetCommands = new ArrayList<>(resetCommands); + return this; + } + + public Builder broadcastReset(String broadcastReset) { + this.broadcastReset = broadcastReset; + return this; + } + + public Builder teleportOnReset(boolean teleportOnReset) { + this.teleportOnReset = teleportOnReset; + return this; + } + + public Builder teleportLocation(Location teleportLocation) { + this.teleportLocation = teleportLocation; + return this; + } + + public Builder actionBarEnabled(boolean actionBarEnabled) { + this.actionBarEnabled = actionBarEnabled; + return this; + } + + public Builder actionBarMessage(String actionBarMessage) { + this.actionBarMessage = actionBarMessage; + return this; + } + + public Builder actionBarRange(double actionBarRange) { + this.actionBarRange = actionBarRange; + return this; + } + + public Builder timerFormat(String timerFormat) { + this.timerFormat = timerFormat; + return this; + } + + public MineConfig build() { + return new MineConfig(this); + } + } +} diff --git a/src/main/java/com/loki/lomines/data/PlayerStats.java b/src/main/java/com/loki/lomines/data/PlayerStats.java new file mode 100644 index 0000000..f264225 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/PlayerStats.java @@ -0,0 +1,57 @@ +package com.loki.lomines.data; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Stores statistics for a single player. + * This is a placeholder implementation that will be completed in task 5.1. + */ +public final class PlayerStats { + + private final UUID playerId; + private final AtomicLong totalBlocks = new AtomicLong(0); + private final ConcurrentHashMap mineStats = new ConcurrentHashMap<>(); + + public PlayerStats(UUID playerId) { + this.playerId = playerId; + } + + public UUID getPlayerId() { + return playerId; + } + + public long getTotalBlocks() { + return totalBlocks.get(); + } + + public long getMineBlocks(String mineName) { + AtomicLong counter = mineStats.get(mineName.toLowerCase()); + return counter != null ? counter.get() : 0; + } + + public void incrementTotal() { + totalBlocks.incrementAndGet(); + } + + public void incrementMine(String mineName) { + mineStats.computeIfAbsent(mineName.toLowerCase(), k -> new AtomicLong(0)) + .incrementAndGet(); + } + + public void setTotalBlocks(long value) { + totalBlocks.set(value); + } + + public void setMineBlocks(String mineName, long value) { + mineStats.computeIfAbsent(mineName.toLowerCase(), k -> new AtomicLong(0)) + .set(value); + } + + public java.util.Map getMineStatsSnapshot() { + java.util.Map snapshot = new java.util.HashMap<>(); + mineStats.forEach((mine, counter) -> snapshot.put(mine, counter.get())); + return snapshot; + } +} diff --git a/src/main/java/com/loki/lomines/data/Reward.java b/src/main/java/com/loki/lomines/data/Reward.java new file mode 100644 index 0000000..2b2ec08 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/Reward.java @@ -0,0 +1,70 @@ +package com.loki.lomines.data; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.Objects; + +/** + * Immutable reward configuration for mine blocks. + * All collections are copied to ensure immutability. + */ +public final class Reward { + private final List materials; + private final double chance; + private final List items; + private final List commands; + private final boolean preventDrops; + + public Reward(List materials, double chance, + List items, List commands, + boolean preventDrops) { + this.materials = List.copyOf(materials); + this.chance = chance; + this.items = List.copyOf(items); + this.commands = List.copyOf(commands); + this.preventDrops = preventDrops; + } + + public boolean matches(Material material) { + return materials.contains(material); + } + + public List getMaterials() { + return materials; + } + + public double getChance() { + return chance; + } + + public List getItems() { + return items; + } + + public List getCommands() { + return commands; + } + + public boolean isPreventDrops() { + return preventDrops; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Reward reward = (Reward) o; + return Double.compare(reward.chance, chance) == 0 && + preventDrops == reward.preventDrops && + Objects.equals(materials, reward.materials) && + Objects.equals(items, reward.items) && + Objects.equals(commands, reward.commands); + } + + @Override + public int hashCode() { + return Objects.hash(materials, chance, items, commands, preventDrops); + } +} diff --git a/src/main/java/com/loki/lomines/data/StatsManager.java b/src/main/java/com/loki/lomines/data/StatsManager.java new file mode 100644 index 0000000..7566a53 --- /dev/null +++ b/src/main/java/com/loki/lomines/data/StatsManager.java @@ -0,0 +1,181 @@ +package com.loki.lomines.data; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages player statistics for all mines. + * Handles loading, saving, and auto-saving of player statistics. + */ +public final class StatsManager { + + private final Map stats = new ConcurrentHashMap<>(); + private final LoMinesPlugin plugin; + private final Path statsFile; + private final Leaderboard leaderboard; + private ScheduledTask saveTask; + + public StatsManager(LoMinesPlugin plugin) { + this.plugin = plugin; + this.statsFile = plugin.getDataFolder().toPath().resolve("stats.yml"); + this.leaderboard = new Leaderboard(this); + } + + /** + * Loads statistics from file. + */ + public void load() throws IOException { + if (!Files.exists(statsFile)) { + plugin.loLogger().info("Stats file does not exist, starting with empty statistics"); + return; + } + + File file = statsFile.toFile(); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + + ConfigurationSection playersSection = yaml.getConfigurationSection("players"); + if (playersSection == null) { + plugin.loLogger().info("No players section found in stats.yml"); + return; + } + + for (String uuidString : playersSection.getKeys(false)) { + try { + UUID playerId = UUID.fromString(uuidString); + ConfigurationSection playerSection = playersSection.getConfigurationSection(uuidString); + + if (playerSection == null) { + continue; + } + + PlayerStats playerStats = getOrCreate(playerId); + + // Load total blocks + long totalBlocks = playerSection.getLong("total", 0); + playerStats.setTotalBlocks(totalBlocks); + + // Load mine-specific stats + ConfigurationSection minesSection = playerSection.getConfigurationSection("mines"); + if (minesSection != null) { + for (String mineName : minesSection.getKeys(false)) { + long mineBlocks = minesSection.getLong(mineName, 0); + playerStats.setMineBlocks(mineName, mineBlocks); + } + } + } catch (IllegalArgumentException e) { + plugin.loLogger().warn("Invalid UUID in stats.yml: " + uuidString); + } + } + + plugin.loLogger().info("Loaded statistics for " + stats.size() + " player(s)"); + } + + /** + * Saves statistics to file. + */ + public void save() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + + for (Map.Entry entry : stats.entrySet()) { + UUID playerId = entry.getKey(); + PlayerStats playerStats = entry.getValue(); + + String path = "players." + playerId.toString(); + + // Save total blocks + yaml.set(path + ".total", playerStats.getTotalBlocks()); + + // Save mine-specific stats + Map mineStats = playerStats.getMineStatsSnapshot(); + if (!mineStats.isEmpty()) { + for (Map.Entry mineEntry : mineStats.entrySet()) { + yaml.set(path + ".mines." + mineEntry.getKey(), mineEntry.getValue()); + } + } + } + + // Ensure parent directory exists + if (!Files.exists(statsFile.getParent())) { + Files.createDirectories(statsFile.getParent()); + } + + yaml.save(statsFile.toFile()); + } + + /** + * Starts auto-save task. + */ + public void startAutoSave() { + // Auto-save every 5 minutes (20 ticks/second * 60 seconds * 5 minutes) + long interval = 20L * 60L * 5L; + + saveTask = Scheduler.get(plugin).runTimerAsync( + () -> { + try { + save(); + plugin.loLogger().info("Statistics auto-saved"); + } catch (IOException e) { + plugin.loLogger().error("Failed to auto-save statistics: " + e.getMessage()); + } + }, + interval, + interval + ); + } + + /** + * Stops auto-save task. + */ + public void stopAutoSave() { + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + } + + /** + * Gets or creates player statistics. + */ + public PlayerStats getOrCreate(UUID playerId) { + return stats.computeIfAbsent(playerId, PlayerStats::new); + } + + /** + * Increments block count for a player in a specific mine. + */ + public void incrementBlocks(UUID playerId, String mineName) { + PlayerStats playerStats = getOrCreate(playerId); + playerStats.incrementTotal(); + playerStats.incrementMine(mineName); + + // Invalidate leaderboard cache + leaderboard.invalidateCache(); + } + + /** + * Returns all player statistics. + */ + public Collection getAllStats() { + return Collections.unmodifiableCollection(stats.values()); + } + + /** + * Returns the leaderboard instance. + */ + public Leaderboard getLeaderboard() { + return leaderboard; + } +} diff --git a/src/main/java/com/loki/lomines/data/package-info.java b/src/main/java/com/loki/lomines/data/package-info.java new file mode 100644 index 0000000..084609c --- /dev/null +++ b/src/main/java/com/loki/lomines/data/package-info.java @@ -0,0 +1,15 @@ +/** + * Data components for statistics and configuration. + *

+ * This package contains classes for data management: + *

    + *
  • {@code MineConfig} - Configuration data model for mines
  • + *
  • {@code Reward} - Reward configuration data model
  • + *
  • {@code PlayerStats} - Player statistics tracking
  • + *
  • {@code StatsManager} - Manager for all player statistics
  • + *
  • {@code Leaderboard} - Leaderboard generation and caching
  • + *
  • {@code ConfigLoader} - Configuration loading and saving
  • + *
  • {@code ConfigParseException} - Exception for configuration parsing errors
  • + *
+ */ +package com.loki.lomines.data; diff --git a/src/main/java/com/loki/lomines/gui/GroupCreateGui.java b/src/main/java/com/loki/lomines/gui/GroupCreateGui.java new file mode 100644 index 0000000..cf632a6 --- /dev/null +++ b/src/main/java/com/loki/lomines/gui/GroupCreateGui.java @@ -0,0 +1,210 @@ +package com.loki.lomines.gui; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mines; +import com.loki.lomines.wand.GroupWandManager; +import com.loki.lomines.wand.GroupWandSession; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.List; + +/** + * 6-row chest: left column = create; center 3×3 = nine sub-mines with shared settings. + */ +public final class GroupCreateGui { + + private static final int SIZE = 54; + /** Create group (same YAML defaults for every created mine). */ + public static final int SLOT_CREATE = 0; + /** Shows /lm group prefix. */ + public static final int SLOT_INFO = 8; + /** 3×3 mine slots mapping index 0..8 */ + private static final int[] MINE_SLOTS = {10, 11, 12, 19, 20, 21, 28, 29, 30}; + + private GroupCreateGui() { + } + + public static void open(LoMinesPlugin plugin, Player player) { + GroupCreateGuiHolder holder = new GroupCreateGuiHolder(player.getUniqueId()); + Inventory inv = Bukkit.createInventory(holder, SIZE, + Component.text("LoMines · группа (9 шахт)", NamedTextColor.DARK_GREEN)); + holder.setInventory(inv); + fill(plugin, player, inv); + player.openInventory(inv); + } + + public static void refresh(LoMinesPlugin plugin, Player player, Inventory inv) { + fill(plugin, player, inv); + } + + private static void fill(LoMinesPlugin plugin, Player player, Inventory inv) { + GroupWandSession session = plugin.getGroupWandManager().getSession(player.getUniqueId()); + for (int i = 0; i < SIZE; i++) { + inv.setItem(i, filler()); + } + inv.setItem(SLOT_CREATE, createButton()); + inv.setItem(SLOT_INFO, infoPaper(session)); + for (int i = 0; i < MINE_SLOTS.length; i++) { + inv.setItem(MINE_SLOTS[i], mineSlotItem(session, i)); + } + } + + private static ItemStack filler() { + ItemStack pane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta meta = pane.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text(" ")); + pane.setItemMeta(meta); + } + return pane; + } + + private static ItemStack createButton() { + ItemStack stack = new ItemStack(Material.LIME_CONCRETE); + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("Создать группу", NamedTextColor.GREEN)); + meta.lore(List.of( + Component.text("Создаёт шахты с префиксом_1 … _9", NamedTextColor.GRAY), + Component.text("для слотов, где заданы оба угла", NamedTextColor.GRAY) + )); + stack.setItemMeta(meta); + } + return stack; + } + + private static ItemStack infoPaper(GroupWandSession session) { + ItemStack stack = new ItemStack(Material.PAPER); + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + if (session.hasBaseName()) { + meta.displayName(Component.text("Префикс: " + session.getBaseName(), NamedTextColor.AQUA)); + meta.lore(List.of( + Component.text("Файлы: " + session.getBaseName() + "_1.yml … _9.yml", NamedTextColor.GRAY), + Component.text("Настройки одинаковые; правьте один и копируйте при нужде", NamedTextColor.DARK_GRAY) + )); + } else { + meta.displayName(Component.text("Задайте префикс", NamedTextColor.RED)); + meta.lore(List.of(Component.text("/lm group <префикс>", NamedTextColor.GRAY))); + } + stack.setItemMeta(meta); + } + return stack; + } + + private static ItemStack mineSlotItem(GroupWandSession session, int index) { + boolean active = session.getActiveSlot() == index; + boolean ready = session.isSlotReady(index); + Material mat; + NamedTextColor nameColor; + if (ready) { + mat = Material.LIME_STAINED_GLASS_PANE; + nameColor = NamedTextColor.GREEN; + } else if (session.getPos1(index) != null) { + mat = Material.YELLOW_STAINED_GLASS_PANE; + nameColor = NamedTextColor.YELLOW; + } else { + mat = Material.RED_STAINED_GLASS_PANE; + nameColor = NamedTextColor.RED; + } + if (active) { + mat = Material.LIGHT_BLUE_STAINED_GLASS_PANE; + } + ItemStack stack = new ItemStack(mat); + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + String label = "Шахта " + (index + 1); + meta.displayName(Component.text(label, nameColor)); + List lore = new ArrayList<>(); + lore.add(Component.text(active ? "Активный слот палочки" : "ПКМ — выбрать активным", NamedTextColor.GRAY)); + lore.add(Component.text( + ready ? "Готово (2 точки)" : (session.getPos1(index) != null ? "Нужна 2-я точка" : "Нужны 2 точки"), + NamedTextColor.DARK_GRAY)); + meta.lore(lore); + stack.setItemMeta(meta); + } + return stack; + } + + public static int mineSlotIndex(int rawSlot) { + for (int i = 0; i < MINE_SLOTS.length; i++) { + if (MINE_SLOTS[i] == rawSlot) { + return i; + } + } + return -1; + } + + /** + * @return true if handled (caller should cancel event) + */ + public static boolean handleClick(LoMinesPlugin plugin, Player player, int rawSlot) { + if (rawSlot < 0 || rawSlot >= SIZE) { + return false; + } + GroupWandManager mgr = plugin.getGroupWandManager(); + GroupWandSession session = mgr.getSession(player.getUniqueId()); + if (rawSlot == SLOT_CREATE) { + runCreate(plugin, player, session); + player.closeInventory(); + return true; + } + int mineIdx = mineSlotIndex(rawSlot); + if (mineIdx >= 0) { + session.setActiveSlot(mineIdx); + Inventory top = player.getOpenInventory().getTopInventory(); + if (top.getHolder() instanceof GroupCreateGuiHolder) { + refresh(plugin, player, top); + } + player.sendMessage(Component.text("Активный слот палочки: " + (mineIdx + 1), NamedTextColor.YELLOW)); + return true; + } + return true; + } + + private static void runCreate(LoMinesPlugin plugin, Player player, GroupWandSession session) { + if (!session.hasBaseName()) { + player.sendMessage(Component.text("Сначала: /lm group <префикс>", NamedTextColor.RED)); + return; + } + String base = session.getBaseName(); + if (!base.matches("^[a-zA-Z0-9_-]+$")) { + player.sendMessage(Component.text("Префикс: только латиница, цифры, _ -", NamedTextColor.RED)); + return; + } + Mines mines = plugin.getMines(); + int created = 0; + int skipped = 0; + for (int i = 0; i < 9; i++) { + if (!session.isSlotReady(i)) { + continue; + } + String mineName = base + "_" + (i + 1); + try { + mines.create(mineName, session.getPos1(i), session.getPos2(i)); + created++; + } catch (IllegalArgumentException e) { + player.sendMessage(Component.text("Пропуск (уже есть): " + mineName, NamedTextColor.GOLD)); + skipped++; + } catch (Exception e) { + player.sendMessage(Component.text("Ошибка " + mineName + ": " + e.getMessage(), NamedTextColor.RED)); + plugin.loLogger().error("Group create failed for " + mineName, e); + } + } + if (created == 0) { + player.sendMessage(Component.text("Нет готовых слотов (2 точки в каждом). Пустые слоты пропущены.", NamedTextColor.RED)); + return; + } + player.sendMessage(Component.text( + "Создано шахт: " + created + (skipped > 0 ? " (пропущено существующих: " + skipped + ")" : ""), + NamedTextColor.GREEN)); + } +} diff --git a/src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java b/src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java new file mode 100644 index 0000000..3de82ed --- /dev/null +++ b/src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java @@ -0,0 +1,32 @@ +package com.loki.lomines.gui; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +import java.util.UUID; + +/** + * Marks the group-create menu and ties it to a player. + */ +public final class GroupCreateGuiHolder implements InventoryHolder { + + private final UUID playerId; + private Inventory inventory; + + public GroupCreateGuiHolder(UUID playerId) { + this.playerId = playerId; + } + + public UUID getPlayerId() { + return playerId; + } + + void setInventory(Inventory inventory) { + this.inventory = inventory; + } + + @Override + public Inventory getInventory() { + return inventory; + } +} diff --git a/src/main/java/com/loki/lomines/gui/package-info.java b/src/main/java/com/loki/lomines/gui/package-info.java new file mode 100644 index 0000000..7496982 --- /dev/null +++ b/src/main/java/com/loki/lomines/gui/package-info.java @@ -0,0 +1,13 @@ +/** + * GUI editors for mine configuration. + *

+ * This package contains graphical user interface implementations using LoAPI's InventoryGUI: + *

    + *
  • {@code MinesEditor} - List view of all mines
  • + *
  • {@code MineEditor} - Main menu for a specific mine
  • + *
  • {@code MineContentsEditor} - Editor for mine block contents
  • + *
  • {@code MineRewardsEditor} - List view of mine rewards
  • + *
  • {@code MineRewardEditor} - Editor for a specific reward
  • + *
+ */ +package com.loki.lomines.gui; diff --git a/src/main/java/com/loki/lomines/handler/ActionBarHandler.java b/src/main/java/com/loki/lomines/handler/ActionBarHandler.java new file mode 100644 index 0000000..2bb5bc2 --- /dev/null +++ b/src/main/java/com/loki/lomines/handler/ActionBarHandler.java @@ -0,0 +1,91 @@ +package com.loki.lomines.handler; + +import com.loki.lomines.core.Mine; +import com.loki.lomines.util.Cuboid; +import com.loki.lomines.util.TimeFormatter; +import dev.lolib.utils.ActionBar; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +/** + * Handles action bar message display for mines. + * Sends formatted messages to players within range of the mine. + */ +public final class ActionBarHandler { + + private final Mine mine; + + public ActionBarHandler(Mine mine) { + this.mine = mine; + } + + /** + * Sends action bar messages to all players within range of the mine. + */ + public void sendToNearbyPlayers() { + if (!mine.getConfig().isActionBarEnabled()) { + return; + } + + Location center = calculateCenter(); + if (center == null || center.getWorld() == null) { + return; + } + + double range = mine.getConfig().getActionBarRange(); + String message = formatMessage(); + + center.getWorld().getNearbyEntities(center, range, range, range).stream() + .filter(entity -> entity instanceof Player) + .map(entity -> (Player) entity) + .forEach(player -> ActionBar.send(player, message)); + } + + /** + * Calculates the center point of the mine. + * Uses the center of the first region. + * + * @return The center location, or null if no regions exist + */ + private Location calculateCenter() { + if (mine.getRegions().isEmpty()) { + return null; + } + + Cuboid firstRegion = mine.getRegions().get(0); + int centerX = (firstRegion.getMinX() + firstRegion.getMaxX()) / 2; + int centerY = (firstRegion.getMinY() + firstRegion.getMaxY()) / 2; + int centerZ = (firstRegion.getMinZ() + firstRegion.getMaxZ()) / 2; + + return new Location(firstRegion.getWorld(), centerX, centerY, centerZ); + } + + /** + * Formats the action bar message with placeholders. + * + * @return The formatted message + */ + private String formatMessage() { + String template = mine.getConfig().getActionBarMessage(); + + String formatted = template + .replace("%mine%", mine.getName()) + .replace("%percent%", String.format("%.1f", mine.getPercentFilled())) + .replace("%blocks%", String.valueOf(mine.getBlocks())) + .replace("%total%", String.valueOf(mine.getTotalVolume())) + .replace("%time%", formatTime()); + + return formatted; + } + + /** + * Formats the time remaining until reset. + * + * @return Formatted time string + */ + private String formatTime() { + int remainingTicks = mine.getConfig().getResetTicks() - mine.getTicks(); + String format = mine.getConfig().getTimerFormat(); + return TimeFormatter.format(remainingTicks, format); + } +} diff --git a/src/main/java/com/loki/lomines/handler/MineBlockHandler.java b/src/main/java/com/loki/lomines/handler/MineBlockHandler.java new file mode 100644 index 0000000..ef81ec4 --- /dev/null +++ b/src/main/java/com/loki/lomines/handler/MineBlockHandler.java @@ -0,0 +1,58 @@ +package com.loki.lomines.handler; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mine; +import com.loki.lomines.data.MineConfig; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; + +/** + * Handles block break events within a mine. + * Coordinates reward checking, statistics updates, and reset condition checks. + */ +public final class MineBlockHandler { + + private final Mine mine; + private final LoMinesPlugin plugin; + private final MineRewardHandler rewardHandler; + + public MineBlockHandler(Mine mine, LoMinesPlugin plugin) { + this.mine = mine; + this.plugin = plugin; + this.rewardHandler = new MineRewardHandler(mine.getConfig(), plugin); + } + + /** + * Handles a block break event in the mine. + * + * @param player The player who broke the block + * @param block The block that was broken + */ + public void handle(Player player, Block block) { + int remaining = mine.getBlocksAtomic().decrementAndGet(); + + rewardHandler.checkRewards(player, block); + + if (plugin.getConfig().getBoolean("statistics-enabled", true)) { + plugin.getStatsManager().incrementBlocks(player.getUniqueId(), mine.getName()); + } + + checkResetConditions(remaining); + } + + /** + * Checks if reset conditions are met and triggers reset if needed. + */ + private void checkResetConditions(int remaining) { + MineConfig config = mine.getConfig(); + int total = mine.getTotalVolume(); + if (total <= 0) { + return; + } + double percent = (double) remaining / total * 100.0; + + if (config.isResetOnPercentEnabled() && percent <= config.getResetPercent()) { + mine.reset(false); + } + } +} diff --git a/src/main/java/com/loki/lomines/handler/MineResetHandler.java b/src/main/java/com/loki/lomines/handler/MineResetHandler.java new file mode 100644 index 0000000..da9507e --- /dev/null +++ b/src/main/java/com/loki/lomines/handler/MineResetHandler.java @@ -0,0 +1,152 @@ +package com.loki.lomines.handler; + +import dev.lolib.scheduler.Scheduler; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mine; +import com.loki.lomines.data.FillMode; +import com.loki.lomines.util.Cuboid; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Handles mine reset operations. + * Ensures thread-safe reset execution and prevents concurrent resets. + * + *

Reset process:

+ *
    + *
  1. Check if reset is already running (atomic check)
  2. + *
  3. Fill all regions asynchronously via BlockSetter
  4. + *
  5. Wait for all regions to complete
  6. + *
  7. Execute post-reset actions in main thread
  8. + *
+ */ +public final class MineResetHandler { + + private final Mine mine; + private final LoMinesPlugin plugin; + private final AtomicBoolean running = new AtomicBoolean(false); + + public MineResetHandler(Mine mine, LoMinesPlugin plugin) { + this.mine = mine; + this.plugin = plugin; + } + + /** + * Resets the mine by filling all regions with blocks. + * If a reset is already in progress, this call is ignored. + * + * @param silent If true, suppresses broadcast messages + */ + public void reset(boolean silent) { + if (!running.compareAndSet(false, true)) { + return; + } + + try { + resetAllRegions(silent); + } catch (Exception e) { + running.set(false); + plugin.loLogger().error("Failed to reset mine " + mine.getName() + ": " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Fills all regions asynchronously and coordinates completion. + */ + private void resetAllRegions(boolean silent) { + List regions = mine.getRegions(); + AtomicInteger completed = new AtomicInteger(0); + AtomicInteger totalBlocks = new AtomicInteger(0); + + if (mine.getBlockSetter() == null) { + plugin.loLogger().warn("BlockSetter not initialized for mine " + mine.getName()); + running.set(false); + return; + } + + if (mine.getConfig().getFillMode() == FillMode.MASK) { + mine.getBlockSetter().fillAtLocations(mine.getConfig().getMaskPositions(), placed -> + Scheduler.get(plugin).run(() -> onResetComplete(placed, silent))); + return; + } + + for (Cuboid region : regions) { + mine.getBlockSetter().fill(region, blocksSet -> { + totalBlocks.addAndGet(blocksSet); + + if (completed.incrementAndGet() == regions.size()) { + Scheduler.get(plugin).run(() -> { + onResetComplete(totalBlocks.get(), silent); + }); + } + }); + } + } + + /** + * Called when all regions have been filled. + * Executes post-reset actions in the main thread. + */ + private void onResetComplete(int totalBlocks, boolean silent) { + mine.getBlocksAtomic().set(totalBlocks); + mine.getTicksAtomic().set(0); + + executeResetCommands(); + + if (!silent) { + broadcastReset(); + } + + if (mine.getConfig().isTeleportOnReset()) { + teleportPlayers(); + } + + running.set(false); + } + + /** + * Executes commands configured for reset. + */ + private void executeResetCommands() { + List commands = mine.getConfig().getResetCommands(); + for (String command : commands) { + String parsed = command.replace("%mine%", mine.getName()); + plugin.getServer().dispatchCommand(plugin.getServer().getConsoleSender(), parsed); + } + } + + /** + * Broadcasts reset message to players. + */ + private void broadcastReset() { + String message = mine.getConfig().getBroadcastReset(); + if (message != null && !message.isEmpty()) { + String formatted = message.replace("%mine%", mine.getName()); + Component component = MiniMessage.miniMessage().deserialize(formatted); + plugin.getServer().broadcast(component); + } + } + + /** + * Teleports players standing inside the mine to the configured location. + */ + private void teleportPlayers() { + Location dest = mine.getConfig().getTeleportLocation(); + if (dest == null || dest.getWorld() == null) { + return; + } + for (Player p : dest.getWorld().getPlayers()) { + if (mine.contains(p.getLocation())) { + p.teleport(dest); + } + } + } +} diff --git a/src/main/java/com/loki/lomines/handler/MineRewardHandler.java b/src/main/java/com/loki/lomines/handler/MineRewardHandler.java new file mode 100644 index 0000000..1b7bb4d --- /dev/null +++ b/src/main/java/com/loki/lomines/handler/MineRewardHandler.java @@ -0,0 +1,71 @@ +package com.loki.lomines.handler; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.data.MineConfig; +import com.loki.lomines.data.Reward; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.Random; + +/** + * Handles reward distribution for block breaks in mines. + * Checks configured rewards and gives items/executes commands based on chance. + */ +public final class MineRewardHandler { + + private final List rewards; + private final LoMinesPlugin plugin; + private final Random random = new Random(); + + public MineRewardHandler(MineConfig config, LoMinesPlugin plugin) { + this.rewards = config.getRewards(); + this.plugin = plugin; + } + + /** + * Checks and applies rewards for a broken block. + * + * @param player The player who broke the block + * @param block The block that was broken + */ + public void checkRewards(Player player, Block block) { + Material material = block.getType(); + + for (Reward reward : rewards) { + if (reward.matches(material) && rollChance(reward.getChance())) { + giveReward(player, reward); + } + } + } + + /** + * Rolls a random chance check. + * + * @param chance The chance percentage (0-100) + * @return true if the roll succeeds + */ + private boolean rollChance(double chance) { + return random.nextDouble() * 100.0 < chance; + } + + /** + * Gives a reward to the player. + * + * @param player The player to give the reward to + * @param reward The reward to give + */ + private void giveReward(Player player, Reward reward) { + for (ItemStack item : reward.getItems()) { + player.getInventory().addItem(item.clone()); + } + + for (String command : reward.getCommands()) { + String parsed = command.replace("%player%", player.getName()); + plugin.getServer().dispatchCommand(plugin.getServer().getConsoleSender(), parsed); + } + } +} diff --git a/src/main/java/com/loki/lomines/handler/package-info.java b/src/main/java/com/loki/lomines/handler/package-info.java new file mode 100644 index 0000000..3e7b850 --- /dev/null +++ b/src/main/java/com/loki/lomines/handler/package-info.java @@ -0,0 +1,12 @@ +/** + * Handler components implementing business logic. + *

+ * This package contains classes that handle specific aspects of mine operations: + *

    + *
  • {@code MineResetHandler} - Manages mine reset operations
  • + *
  • {@code MineBlockHandler} - Handles block break events in mines
  • + *
  • {@code MineRewardHandler} - Manages reward distribution to players
  • + *
  • {@code ActionBarHandler} - Displays information in action bar
  • + *
+ */ +package com.loki.lomines.handler; diff --git a/src/main/java/com/loki/lomines/integration/IntegrationManager.java b/src/main/java/com/loki/lomines/integration/IntegrationManager.java new file mode 100644 index 0000000..9e8ac0a --- /dev/null +++ b/src/main/java/com/loki/lomines/integration/IntegrationManager.java @@ -0,0 +1,50 @@ +package com.loki.lomines.integration; + +import com.loki.lomines.LoMinesPlugin; +import org.bukkit.Bukkit; + +/** + * Manages integrations with external plugins. + * This is a placeholder implementation that will be completed in task 10.1. + */ +public final class IntegrationManager { + + private final LoMinesPlugin plugin; + + public IntegrationManager(LoMinesPlugin plugin) { + this.plugin = plugin; + } + + /** + * Initializes all available integrations. + */ + public void initAll() { + // Check for PlaceholderAPI + if (isPluginEnabled("PlaceholderAPI")) { + plugin.loLogger().info("PlaceholderAPI integration available"); + } + + // Check for WorldGuard + if (isPluginEnabled("WorldGuard")) { + plugin.loLogger().info("WorldGuard integration available"); + } + + // Check for Oraxen + if (isPluginEnabled("Oraxen")) { + plugin.loLogger().info("Oraxen integration available"); + } + + // Check for ItemsAdder + if (isPluginEnabled("ItemsAdder")) { + plugin.loLogger().info("ItemsAdder integration available"); + } + } + + /** + * Checks if a plugin is enabled. + */ + public boolean isPluginEnabled(String pluginName) { + return Bukkit.getPluginManager().getPlugin(pluginName) != null && + Bukkit.getPluginManager().isPluginEnabled(pluginName); + } +} diff --git a/src/main/java/com/loki/lomines/integration/package-info.java b/src/main/java/com/loki/lomines/integration/package-info.java new file mode 100644 index 0000000..b76e8f8 --- /dev/null +++ b/src/main/java/com/loki/lomines/integration/package-info.java @@ -0,0 +1,11 @@ +/** + * Integration with external plugins. + *

+ * This package contains integrations with third-party plugins: + *

    + *
  • {@code IntegrationManager} - Manages all plugin integrations
  • + *
  • {@code LoMinesPlaceholders} - PlaceholderAPI expansion
  • + *
  • {@code WGRegionManager} - WorldGuard region management
  • + *
+ */ +package com.loki.lomines.integration; diff --git a/src/main/java/com/loki/lomines/listener/BlockBreakListener.java b/src/main/java/com/loki/lomines/listener/BlockBreakListener.java new file mode 100644 index 0000000..1fef4d2 --- /dev/null +++ b/src/main/java/com/loki/lomines/listener/BlockBreakListener.java @@ -0,0 +1,35 @@ +package com.loki.lomines.listener; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mine; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; + +import java.util.Optional; + +/** + * Listens for block break events in mines. + * This is a placeholder implementation that will be completed in task 11.1. + */ +public final class BlockBreakListener implements Listener { + + private final LoMinesPlugin plugin; + + public BlockBreakListener(LoMinesPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBreak(BlockBreakEvent event) { + Optional mine = plugin.getMines().findByLocation(event.getBlock().getLocation()); + + if (mine.isPresent()) { + Mine m = mine.get(); + if (m.appliesToBlock(event.getBlock().getLocation())) { + m.onBlockBreak(event.getPlayer(), event.getBlock()); + } + } + } +} diff --git a/src/main/java/com/loki/lomines/listener/GroupGuiListener.java b/src/main/java/com/loki/lomines/listener/GroupGuiListener.java new file mode 100644 index 0000000..8ce9889 --- /dev/null +++ b/src/main/java/com/loki/lomines/listener/GroupGuiListener.java @@ -0,0 +1,51 @@ +package com.loki.lomines.listener; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.gui.GroupCreateGui; +import com.loki.lomines.gui.GroupCreateGuiHolder; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.InventoryView; + +/** + * Handles clicks in the group-create chest GUI. + */ +public final class GroupGuiListener implements Listener { + + private final LoMinesPlugin plugin; + + public GroupGuiListener(LoMinesPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + InventoryView view = event.getView(); + if (!(view.getTopInventory().getHolder() instanceof GroupCreateGuiHolder holder)) { + return; + } + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + if (!holder.getPlayerId().equals(player.getUniqueId())) { + event.setCancelled(true); + return; + } + event.setCancelled(true); + int raw = event.getRawSlot(); + if (raw >= view.getTopInventory().getSize()) { + return; + } + GroupCreateGui.handleClick(plugin, player, raw); + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof GroupCreateGuiHolder) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/com/loki/lomines/listener/PlayerInteractListener.java b/src/main/java/com/loki/lomines/listener/PlayerInteractListener.java new file mode 100644 index 0000000..40e47d0 --- /dev/null +++ b/src/main/java/com/loki/lomines/listener/PlayerInteractListener.java @@ -0,0 +1,73 @@ +package com.loki.lomines.listener; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.gui.GroupCreateGui; +import com.loki.lomines.wand.GroupWandItem; +import com.loki.lomines.wand.GroupWandSession; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; + +/** + * Group wand: ЛКМ / ПКМ по блоку — углы; Shift+ПКМ — GUI группы. + */ +public final class PlayerInteractListener implements Listener { + + private final LoMinesPlugin plugin; + + public PlayerInteractListener(LoMinesPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = false) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Player player = event.getPlayer(); + if (!GroupWandItem.isWand(plugin, player.getInventory().getItemInMainHand())) { + return; + } + Action action = event.getAction(); + + if (player.isSneaking() && (action == Action.RIGHT_CLICK_AIR || action == Action.RIGHT_CLICK_BLOCK)) { + event.setCancelled(true); + GroupCreateGui.open(plugin, player); + return; + } + + if (action == Action.LEFT_CLICK_BLOCK && event.hasBlock()) { + event.setCancelled(true); + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + GroupWandSession session = plugin.getGroupWandManager().getSession(player.getUniqueId()); + int slot = session.getActiveSlot(); + session.setPos1(slot, block.getLocation()); + player.sendMessage(Component.text( + "Угол 1 для шахты " + (slot + 1) + " установлен.", NamedTextColor.GREEN)); + return; + } + + if (action == Action.RIGHT_CLICK_BLOCK && !player.isSneaking()) { + event.setCancelled(true); + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + GroupWandSession session = plugin.getGroupWandManager().getSession(player.getUniqueId()); + int slot = session.getActiveSlot(); + session.setPos2(slot, block.getLocation()); + player.sendMessage(Component.text( + "Угол 2 для шахты " + (slot + 1) + " установлен.", NamedTextColor.GREEN)); + } + } +} diff --git a/src/main/java/com/loki/lomines/listener/PlayerJoinListener.java b/src/main/java/com/loki/lomines/listener/PlayerJoinListener.java new file mode 100644 index 0000000..3ae2bca --- /dev/null +++ b/src/main/java/com/loki/lomines/listener/PlayerJoinListener.java @@ -0,0 +1,25 @@ +package com.loki.lomines.listener; + +import com.loki.lomines.LoMinesPlugin; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +/** + * Listens for player join events to load statistics. + * This is a placeholder implementation that will be completed in task 11.3. + */ +public final class PlayerJoinListener implements Listener { + + private final LoMinesPlugin plugin; + + public PlayerJoinListener(LoMinesPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + // Load or create player statistics + plugin.getStatsManager().getOrCreate(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/com/loki/lomines/listener/package-info.java b/src/main/java/com/loki/lomines/listener/package-info.java new file mode 100644 index 0000000..ffaacf2 --- /dev/null +++ b/src/main/java/com/loki/lomines/listener/package-info.java @@ -0,0 +1,12 @@ +/** + * Event listeners for Bukkit events. + *

+ * This package contains event listener implementations: + *

    + *
  • {@code BlockBreakListener} - Handles block break events in mines
  • + *
  • {@code PlayerInteractListener} - Handles selection wand interactions
  • + *
  • {@code PlayerJoinListener} - Handles player join for statistics loading
  • + *
  • {@code SelectionParticleTask} - Displays particles for active selections
  • + *
+ */ +package com.loki.lomines.listener; diff --git a/src/main/java/com/loki/lomines/util/BlockKeys.java b/src/main/java/com/loki/lomines/util/BlockKeys.java new file mode 100644 index 0000000..781ce8b --- /dev/null +++ b/src/main/java/com/loki/lomines/util/BlockKeys.java @@ -0,0 +1,21 @@ +package com.loki.lomines.util; + +import org.bukkit.Location; +import org.bukkit.block.Block; + +/** + * Stable string keys for block-aligned locations (world + block integer coords). + */ +public final class BlockKeys { + + private BlockKeys() { + } + + public static String key(Block block) { + return key(block.getLocation()); + } + + public static String key(Location loc) { + return loc.getWorld().getName() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ(); + } +} diff --git a/src/main/java/com/loki/lomines/util/ChunkUtils.java b/src/main/java/com/loki/lomines/util/ChunkUtils.java new file mode 100644 index 0000000..a293316 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/ChunkUtils.java @@ -0,0 +1,185 @@ +package com.loki.lomines.util; + +import org.bukkit.Chunk; +import org.bukkit.World; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +/** + * Utility class for updating chunks after block modifications. + * Automatically detects Paper API availability and uses the most efficient method. + * + *

Paper detection is done through method reflection rather than Class.forName + * to provide a more reliable mechanism as per requirement 21.4.

+ */ +public final class ChunkUtils { + + private static final boolean IS_PAPER = detectPaper(); + private static final Method SEND_CHUNK_CHANGE_METHOD = findSendChunkChangeMethod(); + + private ChunkUtils() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Updates all chunks within the given cuboid region. + * Uses Paper API if available for better performance, otherwise falls back to Bukkit API. + * + * @param region The cuboid region whose chunks should be updated + * @throws IllegalArgumentException if region is null + */ + public static void updateChunks(Cuboid region) { + if (region == null) { + throw new IllegalArgumentException("Region cannot be null"); + } + + Set chunks = getAffectedChunks(region); + World world = region.getWorld(); + + for (ChunkCoordinate coord : chunks) { + Chunk chunk = world.getChunkAt(coord.x, coord.z); + updateChunk(chunk); + } + } + + /** + * Detects if Paper API is available by checking for Paper-specific methods. + * This is more reliable than Class.forName as it checks for actual functionality. + * + * @return true if Paper API is available, false otherwise + */ + private static boolean detectPaper() { + try { + // Check for Paper-specific method in Chunk class + // Paper adds sendChunkChange methods that Bukkit doesn't have + Class chunkClass = Chunk.class; + chunkClass.getMethod("getPluginChunkTickets"); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + /** + * Finds the sendChunkChange method if running on Paper. + * + * @return The sendChunkChange method, or null if not available + */ + private static Method findSendChunkChangeMethod() { + if (!IS_PAPER) { + return null; + } + + try { + Class chunkClass = Chunk.class; + // Paper 1.21 has sendChunkChange() method + return chunkClass.getMethod("sendChunkChange"); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** + * Updates a single chunk using the appropriate API. + * + * @param chunk The chunk to update + */ + private static void updateChunk(Chunk chunk) { + if (IS_PAPER && SEND_CHUNK_CHANGE_METHOD != null) { + updateChunkPaper(chunk); + } else { + updateChunkBukkit(chunk); + } + } + + /** + * Updates a chunk using Paper API. + * + * @param chunk The chunk to update + */ + private static void updateChunkPaper(Chunk chunk) { + try { + SEND_CHUNK_CHANGE_METHOD.invoke(chunk); + } catch (Exception e) { + // Fall back to Bukkit if Paper method fails + updateChunkBukkit(chunk); + } + } + + /** + * Updates a chunk using Bukkit API. + * Forces chunk to be resent to all players by marking it as modified. + * + * @param chunk The chunk to update + */ + private static void updateChunkBukkit(Chunk chunk) { + // Bukkit doesn't have a direct chunk update method + // The chunk will be automatically sent to players when they are nearby + // We can force a refresh by unloading and reloading, but that's expensive + // Instead, we rely on the natural chunk update mechanism + // Players will see the changes when the chunk is next sent to them + + // For Bukkit, we can use the World's refreshChunk method if available + World world = chunk.getWorld(); + try { + // Try to use refreshChunk if it exists (some Bukkit versions have it) + Method refreshChunk = World.class.getMethod("refreshChunk", int.class, int.class); + refreshChunk.invoke(world, chunk.getX(), chunk.getZ()); + } catch (Exception e) { + // If refreshChunk doesn't exist, chunks will update naturally + // This is acceptable as blocks are already set + } + } + + /** + * Calculates all chunk coordinates that are affected by the given region. + * + * @param region The cuboid region + * @return Set of chunk coordinates + */ + private static Set getAffectedChunks(Cuboid region) { + Set chunks = new HashSet<>(); + + // Convert block coordinates to chunk coordinates + int minChunkX = region.getMinX() >> 4; + int maxChunkX = region.getMaxX() >> 4; + int minChunkZ = region.getMinZ() >> 4; + int maxChunkZ = region.getMaxZ() >> 4; + + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + chunks.add(new ChunkCoordinate(chunkX, chunkZ)); + } + } + + return chunks; + } + + /** + * Simple immutable holder for chunk coordinates. + */ + private static final class ChunkCoordinate { + final int x; + final int z; + + ChunkCoordinate(int x, int z) { + this.x = x; + this.z = z; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof ChunkCoordinate)) return false; + ChunkCoordinate other = (ChunkCoordinate) obj; + return x == other.x && z == other.z; + } + + @Override + public int hashCode() { + return 31 * x + z; + } + } +} diff --git a/src/main/java/com/loki/lomines/util/Cuboid.java b/src/main/java/com/loki/lomines/util/Cuboid.java new file mode 100644 index 0000000..d4f3b91 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/Cuboid.java @@ -0,0 +1,182 @@ +package com.loki.lomines.util; + +import org.bukkit.Location; +import org.bukkit.World; + +/** + * Immutable representation of a rectangular 3D region (cuboid) in a Minecraft world. + * Used to define mine boundaries and check block containment. + * + *

This class is thread-safe due to its immutability.

+ */ +public final class Cuboid { + + private final World world; + private final int minX; + private final int minY; + private final int minZ; + private final int maxX; + private final int maxY; + private final int maxZ; + + /** + * Creates a cuboid from two corner locations. + * The locations must be in the same world. + * + * @param loc1 First corner location + * @param loc2 Second corner location (opposite corner) + * @throws IllegalArgumentException if locations are in different worlds or null + */ + public Cuboid(Location loc1, Location loc2) { + if (loc1 == null || loc2 == null) { + throw new IllegalArgumentException("Locations cannot be null"); + } + + if (loc1.getWorld() == null || loc2.getWorld() == null) { + throw new IllegalArgumentException("Location worlds cannot be null"); + } + + if (!loc1.getWorld().equals(loc2.getWorld())) { + throw new IllegalArgumentException("Locations must be in the same world"); + } + + this.world = loc1.getWorld(); + + // Calculate min/max coordinates + this.minX = Math.min(loc1.getBlockX(), loc2.getBlockX()); + this.minY = Math.min(loc1.getBlockY(), loc2.getBlockY()); + this.minZ = Math.min(loc1.getBlockZ(), loc2.getBlockZ()); + + this.maxX = Math.max(loc1.getBlockX(), loc2.getBlockX()); + this.maxY = Math.max(loc1.getBlockY(), loc2.getBlockY()); + this.maxZ = Math.max(loc1.getBlockZ(), loc2.getBlockZ()); + } + + /** + * Checks if a location is contained within this cuboid. + * + * @param location The location to check + * @return true if the location is within this cuboid, false otherwise + */ + public boolean contains(Location location) { + if (location == null || location.getWorld() == null) { + return false; + } + + if (!location.getWorld().equals(world)) { + return false; + } + + int x = location.getBlockX(); + int y = location.getBlockY(); + int z = location.getBlockZ(); + + return x >= minX && x <= maxX + && y >= minY && y <= maxY + && z >= minZ && z <= maxZ; + } + + /** + * Calculates the volume of this cuboid in blocks. + * + * @return The number of blocks contained in this cuboid + */ + public int getVolume() { + int width = maxX - minX + 1; + int height = maxY - minY + 1; + int depth = maxZ - minZ + 1; + return width * height * depth; + } + + /** + * Gets the world this cuboid is in. + * + * @return The world + */ + public World getWorld() { + return world; + } + + /** + * Gets the minimum X coordinate. + * + * @return Minimum X + */ + public int getMinX() { + return minX; + } + + /** + * Gets the minimum Y coordinate. + * + * @return Minimum Y + */ + public int getMinY() { + return minY; + } + + /** + * Gets the minimum Z coordinate. + * + * @return Minimum Z + */ + public int getMinZ() { + return minZ; + } + + /** + * Gets the maximum X coordinate. + * + * @return Maximum X + */ + public int getMaxX() { + return maxX; + } + + /** + * Gets the maximum Y coordinate. + * + * @return Maximum Y + */ + public int getMaxY() { + return maxY; + } + + /** + * Gets the maximum Z coordinate. + * + * @return Maximum Z + */ + public int getMaxZ() { + return maxZ; + } + + @Override + public String toString() { + return String.format("Cuboid[world=%s, min=(%d,%d,%d), max=(%d,%d,%d), volume=%d]", + world.getName(), minX, minY, minZ, maxX, maxY, maxZ, getVolume()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Cuboid)) return false; + + Cuboid other = (Cuboid) obj; + return world.equals(other.world) + && minX == other.minX && minY == other.minY && minZ == other.minZ + && maxX == other.maxX && maxY == other.maxY && maxZ == other.maxZ; + } + + @Override + public int hashCode() { + int result = world.hashCode(); + result = 31 * result + minX; + result = 31 * result + minY; + result = 31 * result + minZ; + result = 31 * result + maxX; + result = 31 * result + maxY; + result = 31 * result + maxZ; + return result; + } +} diff --git a/src/main/java/com/loki/lomines/util/LocationParser.java b/src/main/java/com/loki/lomines/util/LocationParser.java new file mode 100644 index 0000000..c527aa3 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/LocationParser.java @@ -0,0 +1,96 @@ +package com.loki.lomines.util; + +import com.loki.lomines.data.ConfigParseException; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class for parsing and formatting Location objects to/from string format. + * Format: "world;x;y;z;yaw;pitch" + */ +public final class LocationParser { + + private static final Pattern LOCATION_PATTERN = + Pattern.compile("([^;]+);(-?\\d+\\.?\\d*);(-?\\d+\\.?\\d*);(-?\\d+\\.?\\d*);(-?\\d+\\.?\\d*);(-?\\d+\\.?\\d*)"); + + private LocationParser() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Parses a location string in the format "world;x;y;z;yaw;pitch". + * + * @param locationString the location string to parse + * @return the parsed Location object + * @throws ConfigParseException if the string format is invalid or the world doesn't exist + */ + public static Location parse(String locationString) throws ConfigParseException { + if (locationString == null || locationString.trim().isEmpty()) { + throw new ConfigParseException("Location string cannot be null or empty"); + } + + Matcher matcher = LOCATION_PATTERN.matcher(locationString.trim()); + if (!matcher.matches()) { + throw new ConfigParseException( + "Invalid location format: '" + locationString + "'. " + + "Expected format: 'world;x;y;z;yaw;pitch'" + ); + } + + try { + String worldName = matcher.group(1); + double x = Double.parseDouble(matcher.group(2)); + double y = Double.parseDouble(matcher.group(3)); + double z = Double.parseDouble(matcher.group(4)); + float yaw = Float.parseFloat(matcher.group(5)); + float pitch = Float.parseFloat(matcher.group(6)); + + World world = Bukkit.getWorld(worldName); + if (world == null) { + throw new ConfigParseException( + "World '" + worldName + "' does not exist or is not loaded" + ); + } + + return new Location(world, x, y, z, yaw, pitch); + + } catch (NumberFormatException e) { + throw new ConfigParseException( + "Invalid number format in location string: '" + locationString + "'", + e + ); + } + } + + /** + * Formats a Location object to a string in the format "world;x;y;z;yaw;pitch". + * + * @param location the location to format + * @return the formatted location string + * @throws IllegalArgumentException if location or its world is null + */ + public static String format(Location location) { + if (location == null) { + throw new IllegalArgumentException("Location cannot be null"); + } + + World world = location.getWorld(); + if (world == null) { + throw new IllegalArgumentException("Location world cannot be null"); + } + + return String.format(Locale.US, "%s;%.2f;%.2f;%.2f;%.2f;%.2f", + world.getName(), + location.getX(), + location.getY(), + location.getZ(), + location.getYaw(), + location.getPitch() + ); + } +} diff --git a/src/main/java/com/loki/lomines/util/MaskScanner.java b/src/main/java/com/loki/lomines/util/MaskScanner.java new file mode 100644 index 0000000..c39e489 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/MaskScanner.java @@ -0,0 +1,45 @@ +package com.loki.lomines.util; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; + +import java.util.ArrayList; +import java.util.List; + +/** + * Finds all blocks of a given vanilla material inside cuboid regions (marker paint for mask mode). + */ +public final class MaskScanner { + + private MaskScanner() { + } + + /** + * Collects block centers for every block whose type equals {@code marker} inside {@code regions}. + */ + public static List scan(List regions, Material marker) { + List out = new ArrayList<>(); + for (Cuboid region : regions) { + World world = region.getWorld(); + int minX = region.getMinX(); + int maxX = region.getMaxX(); + int minY = region.getMinY(); + int maxY = region.getMaxY(); + int minZ = region.getMinZ(); + int maxZ = region.getMaxZ(); + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + Block block = world.getBlockAt(x, y, z); + if (block.getType() == marker) { + out.add(block.getLocation()); + } + } + } + } + } + return out; + } +} diff --git a/src/main/java/com/loki/lomines/util/Selection.java b/src/main/java/com/loki/lomines/util/Selection.java new file mode 100644 index 0000000..a229607 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/Selection.java @@ -0,0 +1,138 @@ +package com.loki.lomines.util; + +import org.bukkit.Location; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Manages selection points for defining mine regions. + * Stores up to 10 points (5 pairs) for multi-regional mines. + * Points are organized in pairs where indices 2i and 2i+1 form a pair for i ∈ [0, 4]. + * + *

This class is NOT thread-safe and should be used per-player.

+ */ +public final class Selection { + + private static final int MAX_POINTS = 10; + private static final int MAX_PAIRS = 5; + + private final Location[] points; + + /** + * Creates a new empty selection. + */ + public Selection() { + this.points = new Location[MAX_POINTS]; + } + + /** + * Sets a point at the specified index. + * + * @param index The point index (0-9) + * @param location The location to set + * @throws IllegalArgumentException if index is out of bounds + */ + public void setPoint(int index, Location location) { + validateIndex(index); + points[index] = location; + } + + /** + * Gets a point at the specified index. + * + * @param index The point index (0-9) + * @return Optional containing the location if set, empty otherwise + * @throws IllegalArgumentException if index is out of bounds + */ + public Optional getPoint(int index) { + validateIndex(index); + return Optional.ofNullable(points[index]); + } + + /** + * Gets a pair of points as an array. + * A pair consists of points at indices 2*pairIndex and 2*pairIndex+1. + * + * @param pairIndex The pair index (0-4) + * @return Array of two locations [point1, point2], may contain nulls + * @throws IllegalArgumentException if pairIndex is out of bounds + */ + public Location[] getPair(int pairIndex) { + validatePairIndex(pairIndex); + int firstIndex = pairIndex * 2; + int secondIndex = firstIndex + 1; + return new Location[] { points[firstIndex], points[secondIndex] }; + } + + /** + * Checks if a pair has both points set. + * + * @param pairIndex The pair index (0-4) + * @return true if both points in the pair are set, false otherwise + * @throws IllegalArgumentException if pairIndex is out of bounds + */ + public boolean hasPair(int pairIndex) { + validatePairIndex(pairIndex); + int firstIndex = pairIndex * 2; + int secondIndex = firstIndex + 1; + return points[firstIndex] != null && points[secondIndex] != null; + } + + /** + * Clears all selection points. + */ + public void clear() { + for (int i = 0; i < MAX_POINTS; i++) { + points[i] = null; + } + } + + /** + * Converts all complete pairs to Cuboid regions. + * Only pairs with both points set are converted. + * + * @return List of Cuboid regions created from complete pairs + */ + public List toCuboids() { + List cuboids = new ArrayList<>(); + + for (int pairIndex = 0; pairIndex < MAX_PAIRS; pairIndex++) { + if (hasPair(pairIndex)) { + Location[] pair = getPair(pairIndex); + cuboids.add(new Cuboid(pair[0], pair[1])); + } + } + + return cuboids; + } + + /** + * Validates that an index is within valid bounds [0, 9]. + * + * @param index The index to validate + * @throws IllegalArgumentException if index is out of bounds + */ + private void validateIndex(int index) { + if (index < 0 || index >= MAX_POINTS) { + throw new IllegalArgumentException( + "Point index must be between 0 and " + (MAX_POINTS - 1) + ", got: " + index + ); + } + } + + /** + * Validates that a pair index is within valid bounds [0, 4]. + * + * @param pairIndex The pair index to validate + * @throws IllegalArgumentException if pairIndex is out of bounds + */ + private void validatePairIndex(int pairIndex) { + if (pairIndex < 0 || pairIndex >= MAX_PAIRS) { + throw new IllegalArgumentException( + "Pair index must be between 0 and " + (MAX_PAIRS - 1) + ", got: " + pairIndex + ); + } + } +} diff --git a/src/main/java/com/loki/lomines/util/SelectionManager.java b/src/main/java/com/loki/lomines/util/SelectionManager.java new file mode 100644 index 0000000..bd9ce11 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/SelectionManager.java @@ -0,0 +1,86 @@ +package com.loki.lomines.util; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages player selections for mine region definition. + * Stores a Selection object for each player, allowing multiple players + * to have independent selections simultaneously. + * + *

This class is thread-safe and can be accessed from multiple threads.

+ * + *

Validates: Requirements 12.7, 16.7

+ */ +public final class SelectionManager { + + private final Map selections; + + /** + * Creates a new SelectionManager with thread-safe storage. + */ + public SelectionManager() { + this.selections = new ConcurrentHashMap<>(); + } + + /** + * Gets the selection for a player, creating a new one if it doesn't exist. + * + * @param playerId The player's UUID + * @return The player's Selection object + * @throws IllegalArgumentException if playerId is null + */ + public Selection getSelection(UUID playerId) { + validatePlayerId(playerId); + return selections.computeIfAbsent(playerId, id -> new Selection()); + } + + /** + * Gets the selection for a player if it exists. + * + * @param playerId The player's UUID + * @return Optional containing the Selection if it exists, empty otherwise + * @throws IllegalArgumentException if playerId is null + */ + public Optional findSelection(UUID playerId) { + validatePlayerId(playerId); + return Optional.ofNullable(selections.get(playerId)); + } + + /** + * Checks if a player has a selection. + * + * @param playerId The player's UUID + * @return true if the player has a selection, false otherwise + * @throws IllegalArgumentException if playerId is null + */ + public boolean hasSelection(UUID playerId) { + validatePlayerId(playerId); + return selections.containsKey(playerId); + } + + /** + * Clears the selection for a player, removing it from storage. + * + * @param playerId The player's UUID + * @throws IllegalArgumentException if playerId is null + */ + public void clearSelection(UUID playerId) { + validatePlayerId(playerId); + selections.remove(playerId); + } + + /** + * Validates that a player ID is not null. + * + * @param playerId The player ID to validate + * @throws IllegalArgumentException if playerId is null + */ + private void validatePlayerId(UUID playerId) { + if (playerId == null) { + throw new IllegalArgumentException("Player ID cannot be null"); + } + } +} diff --git a/src/main/java/com/loki/lomines/util/TimeFormatter.java b/src/main/java/com/loki/lomines/util/TimeFormatter.java new file mode 100644 index 0000000..0118927 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/TimeFormatter.java @@ -0,0 +1,67 @@ +package com.loki.lomines.util; + +/** + * Utility class for formatting time from Minecraft ticks to human-readable formats. + * Minecraft runs at 20 ticks per second. + */ +public final class TimeFormatter { + + private static final int TICKS_PER_SECOND = 20; + private static final int SECONDS_PER_MINUTE = 60; + private static final int MINUTES_PER_HOUR = 60; + + private TimeFormatter() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Formats time from Minecraft ticks to a human-readable string. + * + * @param ticks the number of Minecraft ticks + * @param format the desired format: "mm:ss", "hh:mm:ss", "s", "m", or "h" + * @return the formatted time string + * @throws IllegalArgumentException if the format is not recognized + */ + public static String format(int ticks, String format) { + if (format == null || format.trim().isEmpty()) { + throw new IllegalArgumentException("Format cannot be null or empty"); + } + + if (ticks < 0) { + ticks = 0; + } + + int totalSeconds = ticks / TICKS_PER_SECOND; + + return switch (format.toLowerCase()) { + case "mm:ss" -> formatMinutesSeconds(totalSeconds); + case "hh:mm:ss" -> formatHoursMinutesSeconds(totalSeconds); + case "s" -> String.valueOf(totalSeconds); + case "m" -> String.valueOf(totalSeconds / SECONDS_PER_MINUTE); + case "h" -> String.valueOf(totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)); + default -> throw new IllegalArgumentException( + "Unknown time format: '" + format + "'. " + + "Supported formats: mm:ss, hh:mm:ss, s, m, h" + ); + }; + } + + /** + * Formats time as "mm:ss" (minutes:seconds). + */ + private static String formatMinutesSeconds(int totalSeconds) { + int minutes = totalSeconds / SECONDS_PER_MINUTE; + int seconds = totalSeconds % SECONDS_PER_MINUTE; + return String.format("%02d:%02d", minutes, seconds); + } + + /** + * Formats time as "hh:mm:ss" (hours:minutes:seconds). + */ + private static String formatHoursMinutesSeconds(int totalSeconds) { + int hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR); + int minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR; + int seconds = totalSeconds % SECONDS_PER_MINUTE; + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } +} diff --git a/src/main/java/com/loki/lomines/util/package-info.java b/src/main/java/com/loki/lomines/util/package-info.java new file mode 100644 index 0000000..4b73026 --- /dev/null +++ b/src/main/java/com/loki/lomines/util/package-info.java @@ -0,0 +1,14 @@ +/** + * Utility classes and helpers. + *

+ * This package contains utility classes used throughout the plugin: + *

    + *
  • {@code Cuboid} - Represents a rectangular region in the world
  • + *
  • {@code Selection} - Manages player region selections
  • + *
  • {@code SelectionManager} - Manages selections for all players
  • + *
  • {@code LocationParser} - Parses and formats location strings
  • + *
  • {@code TimeFormatter} - Formats time values for display
  • + *
  • {@code ChunkUtils} - Utilities for chunk operations
  • + *
+ */ +package com.loki.lomines.util; diff --git a/src/main/java/com/loki/lomines/wand/GroupWandItem.java b/src/main/java/com/loki/lomines/wand/GroupWandItem.java new file mode 100644 index 0000000..d80cf1a --- /dev/null +++ b/src/main/java/com/loki/lomines/wand/GroupWandItem.java @@ -0,0 +1,59 @@ +package com.loki.lomines.wand; + +import com.loki.lomines.LoMinesPlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import java.util.List; + +/** + * Blaze rod marked with PDC so only this item drives group wand behaviour. + */ +public final class GroupWandItem { + + private static final String KEY = "group_wand"; + /** Same namespace as {@code plugin.yml} name, avoids IDE needing full {@link org.bukkit.plugin.Plugin} classpath. */ + private static final String NAMESPACE = "lomines"; + + private GroupWandItem() { + } + + public static NamespacedKey key(LoMinesPlugin plugin) { + return new NamespacedKey(NAMESPACE, KEY); + } + + public static ItemStack create(LoMinesPlugin plugin) { + ItemStack item = new ItemStack(Material.BLAZE_ROD); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("LoMines · группа", NamedTextColor.GOLD)); + meta.lore(List.of( + Component.text("ЛКМ блок — угол 1 (слот ", NamedTextColor.GRAY) + .append(Component.text("1–9", NamedTextColor.YELLOW)) + .append(Component.text(" в меню)", NamedTextColor.GRAY)), + Component.text("ПКМ блок — угол 2", NamedTextColor.GRAY), + Component.text("Shift+ПКМ — меню: 9 шахт, одни настройки", NamedTextColor.GRAY), + Component.text("/lm group <префикс> — имена префикс_1…префикс_9", NamedTextColor.DARK_GRAY) + )); + meta.getPersistentDataContainer().set(key(plugin), PersistentDataType.BYTE, (byte) 1); + item.setItemMeta(meta); + } + return item; + } + + public static boolean isWand(LoMinesPlugin plugin, ItemStack stack) { + if (stack == null || stack.getType() == Material.AIR) { + return false; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return false; + } + return meta.getPersistentDataContainer().has(key(plugin), PersistentDataType.BYTE); + } +} diff --git a/src/main/java/com/loki/lomines/wand/GroupWandManager.java b/src/main/java/com/loki/lomines/wand/GroupWandManager.java new file mode 100644 index 0000000..44c5e31 --- /dev/null +++ b/src/main/java/com/loki/lomines/wand/GroupWandManager.java @@ -0,0 +1,21 @@ +package com.loki.lomines.wand; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-player {@link GroupWandSession} for the group wand workflow. + */ +public final class GroupWandManager { + + private final Map sessions = new ConcurrentHashMap<>(); + + public GroupWandSession getSession(UUID playerId) { + return sessions.computeIfAbsent(playerId, u -> new GroupWandSession()); + } + + public void removeSession(UUID playerId) { + sessions.remove(playerId); + } +} diff --git a/src/main/java/com/loki/lomines/wand/GroupWandSession.java b/src/main/java/com/loki/lomines/wand/GroupWandSession.java new file mode 100644 index 0000000..93a032f --- /dev/null +++ b/src/main/java/com/loki/lomines/wand/GroupWandSession.java @@ -0,0 +1,82 @@ +package com.loki.lomines.wand; + +import org.bukkit.Location; + +import java.util.Arrays; + +/** + * One player's progress: up to 9 sub-mines sharing one basename and one shared YAML template, + * each slot has its own cuboid (pos1 + pos2). + */ +public final class GroupWandSession { + + private static final int SLOTS = 9; + + private String baseName = ""; + private int activeSlot; + private final Location[] pos1 = new Location[SLOTS]; + private final Location[] pos2 = new Location[SLOTS]; + + public String getBaseName() { + return baseName; + } + + public void setBaseName(String baseName) { + this.baseName = baseName != null ? baseName.trim() : ""; + } + + public boolean hasBaseName() { + return baseName != null && !baseName.isEmpty(); + } + + public int getActiveSlot() { + return activeSlot; + } + + public void setActiveSlot(int activeSlot) { + if (activeSlot < 0 || activeSlot >= SLOTS) { + throw new IllegalArgumentException("activeSlot 0..8"); + } + this.activeSlot = activeSlot; + } + + public Location getPos1(int index) { + return pos1[index]; + } + + public Location getPos2(int index) { + return pos2[index]; + } + + public void setPos1(int index, Location loc) { + pos1[index] = loc; + } + + public void setPos2(int index, Location loc) { + pos2[index] = loc; + } + + public boolean isSlotReady(int index) { + return pos1[index] != null && pos2[index] != null; + } + + public void clearCorners(int index) { + pos1[index] = null; + pos2[index] = null; + } + + public void clearAll() { + Arrays.fill(pos1, null); + Arrays.fill(pos2, null); + } + + public int countReadySlots() { + int n = 0; + for (int i = 0; i < SLOTS; i++) { + if (isSlotReady(i)) { + n++; + } + } + return n; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..4878d37 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,13 @@ +# LoMines Configuration + +# Enable statistics tracking for players +statistics-enabled: true + +# Enable debug logging +debug: false + +# WorldGuard Integration Settings +worldguard: + enabled: true + auto-create-regions: false + region-priority: 10 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..b000867 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,44 @@ +name: LoMines +version: ${version} +description: ${description} +main: com.loki.lomines.LoMinesPlugin +author: loki +api-version: "1.21" +website: https://github.com/loki/lomines +softdepend: [PlaceholderAPI, WorldGuard, Oraxen, ItemsAdder] + +# LoAPI will handle dependency loading via DependencyManager +# Dependencies: commons-math3 and commons-io will be loaded at runtime + +commands: + lm: + description: Main LoMines command + aliases: [lomines, mine, mines] + permission: lomines.use + usage: / + +permissions: + lomines.admin: + description: Access to all LoMines admin commands + default: op + children: + lomines.use: true + lomines.stats: true + lomines.stats.others: true + lomines.admin.wand: true + lomines.admin.maskscan: true + lomines.stats: + description: View mining stats and leaderboards (/lm stats, /lm top) + default: true + lomines.stats.others: + description: View another player's stats (/lm stats player) + default: op + lomines.admin.maskscan: + description: Scan marker blocks and enable mask fill mode for a mine + default: op + lomines.admin.wand: + description: Group wand and /lm group prefix for batch mine creation + default: op + lomines.use: + description: Basic LoMines usage + default: true diff --git a/src/test/java/com/loki/lomines/command/MineCommandsTest.java b/src/test/java/com/loki/lomines/command/MineCommandsTest.java new file mode 100644 index 0000000..cf061f6 --- /dev/null +++ b/src/test/java/com/loki/lomines/command/MineCommandsTest.java @@ -0,0 +1,150 @@ +package com.loki.lomines.command; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.core.Mine; +import com.loki.lomines.core.Mines; +import net.kyori.adventure.text.Component; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; + +import static org.mockito.Mockito.*; + +/** + * Unit tests for MineCommands class. + */ +class MineCommandsTest { + + @Mock + private LoMinesPlugin plugin; + + @Mock + private Mines mines; + + @Mock + private CommandSender sender; + + @Mock + private Mine mine; + + private MineCommands commands; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(plugin.getMines()).thenReturn(mines); + commands = new MineCommands(plugin); + } + + @Test + void testCreateMineSuccess() throws IOException { + String mineName = "testmine"; + + commands.create(sender, mineName); + + verify(mines).create(mineName); + verify(sender, times(2)).sendMessage(any(Component.class)); + } + + @Test + void testCreateMineAlreadyExists() throws IOException { + String mineName = "existing"; + doThrow(new IllegalArgumentException("Mine already exists")).when(mines).create(mineName); + + commands.create(sender, mineName); + + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testDeleteMineSuccess() throws IOException { + String mineName = "testmine"; + + commands.delete(sender, mineName); + + verify(mines).delete(mineName); + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testDeleteMineNotFound() throws IOException { + String mineName = "nonexistent"; + doThrow(new IllegalArgumentException("Mine not found")).when(mines).delete(mineName); + + commands.delete(sender, mineName); + + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testResetMineSuccess() { + String mineName = "testmine"; + when(mines.get(mineName)).thenReturn(mine); + + commands.reset(sender, mineName, false); + + verify(mine).reset(false); + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testResetMineSilent() { + String mineName = "testmine"; + when(mines.get(mineName)).thenReturn(mine); + + commands.reset(sender, mineName, true); + + verify(mine).reset(true); + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testResetMineNotFound() { + String mineName = "nonexistent"; + when(mines.get(mineName)).thenThrow(new IllegalArgumentException("Mine not found")); + + commands.reset(sender, mineName, false); + + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testReloadSuccess() throws IOException { + Collection mineList = Arrays.asList(mine, mine); + when(mines.getAll()).thenReturn(mineList); + + commands.reload(sender); + + verify(mine, times(2)).stop(); + verify(mines).loadAll(); + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testListMinesEmpty() { + when(mines.getAll()).thenReturn(Arrays.asList()); + + commands.list(sender); + + verify(sender).sendMessage(any(Component.class)); + } + + @Test + void testListMinesWithData() { + when(mines.getAll()).thenReturn(Arrays.asList(mine, mine)); + when(mine.getName()).thenReturn("mine1"); + when(mine.getBlocks()).thenReturn(100); + when(mine.getTotalVolume()).thenReturn(200); + when(mine.getPercentFilled()).thenReturn(50.0); + + commands.list(sender); + + verify(sender, atLeast(3)).sendMessage(any(Component.class)); + } +} diff --git a/src/test/java/com/loki/lomines/core/MineTest.java b/src/test/java/com/loki/lomines/core/MineTest.java new file mode 100644 index 0000000..8c32d8f --- /dev/null +++ b/src/test/java/com/loki/lomines/core/MineTest.java @@ -0,0 +1,120 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import com.loki.lomines.block.BukkitBlockSetter; +import com.loki.lomines.block.ItemsAdderBlockSetter; +import com.loki.lomines.block.OraxenBlockSetter; +import com.loki.lomines.data.MineConfig; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * Unit tests for Mine class, specifically testing BlockSetter creation logic. + */ +class MineTest { + + @Mock + private LoMinesPlugin plugin; + + @Mock + private World world; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testCreateBlockSetter_WithOraxenPrefix_CreatesOraxenBlockSetter() { + // Arrange + Map blocks = new HashMap<>(); + blocks.put("oraxen:custom_ore", 50.0); + blocks.put("oraxen:rare_ore", 30.0); + + MineConfig config = createTestConfig(blocks); + + // Act + Mine mine = new Mine("test_mine", config, plugin); + + // Assert + assertNotNull(mine.getBlockSetter()); + assertTrue(mine.getBlockSetter() instanceof OraxenBlockSetter, + "Expected OraxenBlockSetter for oraxen: prefix"); + } + + @Test + void testCreateBlockSetter_WithItemsAdderPrefix_CreatesItemsAdderBlockSetter() { + // Arrange + Map blocks = new HashMap<>(); + blocks.put("itemsadder:custom_block", 60.0); + blocks.put("itemsadder:special_block", 40.0); + + MineConfig config = createTestConfig(blocks); + + // Act + Mine mine = new Mine("test_mine", config, plugin); + + // Assert + assertNotNull(mine.getBlockSetter()); + assertTrue(mine.getBlockSetter() instanceof ItemsAdderBlockSetter, + "Expected ItemsAdderBlockSetter for itemsadder: prefix"); + } + + @Test + void testCreateBlockSetter_WithoutPrefix_CreatesBukkitBlockSetter() { + // Arrange + Map blocks = new HashMap<>(); + blocks.put("STONE", 50.0); + blocks.put("COAL_ORE", 30.0); + blocks.put("IRON_ORE", 20.0); + + MineConfig config = createTestConfig(blocks); + + // Act + Mine mine = new Mine("test_mine", config, plugin); + + // Assert + assertNotNull(mine.getBlockSetter()); + assertTrue(mine.getBlockSetter() instanceof BukkitBlockSetter, + "Expected BukkitBlockSetter for vanilla blocks without prefix"); + } + + @Test + void testCreateBlockSetter_WithEmptyBlocks_ThrowsException() { + // Arrange + Map blocks = new HashMap<>(); + MineConfig config = createTestConfig(blocks); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new Mine("test_mine", config, plugin) + ); + + assertEquals("Mine must have at least one block type", exception.getMessage()); + } + + private MineConfig createTestConfig(Map blocks) { + when(world.getName()).thenReturn("world"); + + Location loc1 = new Location(world, 0, 0, 0); + Location loc2 = new Location(world, 10, 10, 10); + + return MineConfig.builder() + .selections(List.of(loc1, loc2)) + .blocks(blocks) + .resetTicks(6000) + .build(); + } +} diff --git a/src/test/java/com/loki/lomines/core/MineTickerTest.java b/src/test/java/com/loki/lomines/core/MineTickerTest.java new file mode 100644 index 0000000..57abded --- /dev/null +++ b/src/test/java/com/loki/lomines/core/MineTickerTest.java @@ -0,0 +1,56 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MineTicker class. + * Note: Full integration testing requires a running Bukkit server. + * These tests verify basic construction and null safety. + */ +class MineTickerTest { + + @Mock + private LoMinesPlugin plugin; + + @Mock + private Mines mines; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testConstruction_WithValidParameters_DoesNotThrow() { + // Act & Assert + assertDoesNotThrow(() -> new MineTicker(mines, plugin)); + } + + @Test + void testStop_WhenNotRunning_DoesNotThrow() { + // Arrange + MineTicker ticker = new MineTicker(mines, plugin); + + // Act & Assert + assertDoesNotThrow(() -> ticker.stop()); + } + + @Test + void testMultipleStops_DoNotThrow() { + // Arrange + MineTicker ticker = new MineTicker(mines, plugin); + + // Act & Assert + assertDoesNotThrow(() -> { + ticker.stop(); + ticker.stop(); + ticker.stop(); + }); + } +} diff --git a/src/test/java/com/loki/lomines/core/MinesTest.java b/src/test/java/com/loki/lomines/core/MinesTest.java new file mode 100644 index 0000000..05817c6 --- /dev/null +++ b/src/test/java/com/loki/lomines/core/MinesTest.java @@ -0,0 +1,401 @@ +package com.loki.lomines.core; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.core.LoLogger; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.block.data.BlockData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for Mines registry. + * Tests loading and creating mines. + */ +class MinesTest { + + private Mines mines; + private LoMinesPlugin plugin; + private Scheduler scheduler; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + plugin = mock(LoMinesPlugin.class); + Logger logger = mock(Logger.class); + LoLogger loLogger = mock(LoLogger.class); + scheduler = mock(Scheduler.class); + + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.loLogger()).thenReturn(loLogger); + + mines = new Mines(plugin); + } + + /** + * Helper method to set up Bukkit and Scheduler mocking for tests that create mines. + */ + private void setupBukkitAndScheduler(MockedStatic bukkit, MockedStatic schedulerStatic) { + Server server = mock(Server.class); + World world = mock(World.class); + BlockData blockData = mock(BlockData.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(blockData); + + when(world.getName()).thenReturn("world"); + + schedulerStatic.when(() -> Scheduler.get(plugin)).thenReturn(scheduler); + + // Mock scheduler methods to execute callbacks immediately for testing + ScheduledTask task = mock(ScheduledTask.class); + when(scheduler.runTimer(any(Runnable.class), anyLong(), anyLong())).thenReturn(task); + } + + @Test + void testLoadAllWithNoMinesFolder() throws IOException { + // When mines folder doesn't exist, loadAll should not throw + assertDoesNotThrow(() -> mines.loadAll()); + + // No mines should be loaded + assertEquals(0, mines.getAll().size()); + } + + @Test + void testLoadAllWithEmptyMinesFolder() throws IOException { + // Create empty mines folder + Path minesFolder = tempDir.resolve("mines"); + Files.createDirectories(minesFolder); + + // Load all mines + mines.loadAll(); + + // No mines should be loaded + assertEquals(0, mines.getAll().size()); + } + + @Test + void testLoadAllWithValidMine() throws IOException { + // Create mines folder + Path minesFolder = tempDir.resolve("mines"); + Files.createDirectories(minesFolder); + + // Create a valid mine config file + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 100 + reset: + ticks: 6000 + percent: 10.0 + reset-on-percent: false + actionbar: + enabled: true + message: "Test mine" + range: 50.0 + timer-format: "mm:ss" + teleport-on-reset: false + reset-commands: [] + broadcast-reset: "" + random-rewards: [] + """; + + Path configFile = minesFolder.resolve("testmine.yml"); + Files.writeString(configFile, yamlContent); + + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Load all mines + mines.loadAll(); + + // Verify mine was loaded + assertEquals(1, mines.getAll().size()); + assertTrue(mines.find("testmine").isPresent()); + + Mine mine = mines.get("testmine"); + assertNotNull(mine); + assertEquals("testmine", mine.getName()); + } + } + + @Test + void testLoadAllWithMultipleMines() throws IOException { + // Create mines folder + Path minesFolder = tempDir.resolve("mines"); + Files.createDirectories(minesFolder); + + // Create multiple mine config files + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 100 + reset: + ticks: 6000 + percent: 10.0 + reset-on-percent: false + actionbar: + enabled: true + message: "Test mine" + range: 50.0 + timer-format: "mm:ss" + teleport-on-reset: false + reset-commands: [] + broadcast-reset: "" + random-rewards: [] + """; + + Files.writeString(minesFolder.resolve("mine1.yml"), yamlContent); + Files.writeString(minesFolder.resolve("mine2.yml"), yamlContent); + Files.writeString(minesFolder.resolve("mine3.yml"), yamlContent); + + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Load all mines + mines.loadAll(); + + // Verify all mines were loaded + assertEquals(3, mines.getAll().size()); + assertTrue(mines.find("mine1").isPresent()); + assertTrue(mines.find("mine2").isPresent()); + assertTrue(mines.find("mine3").isPresent()); + } + } + + @Test + void testLoadAllIgnoresNonYamlFiles() throws IOException { + // Create mines folder + Path minesFolder = tempDir.resolve("mines"); + Files.createDirectories(minesFolder); + + // Create a valid mine config file + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 100 + reset: + ticks: 6000 + percent: 10.0 + """; + + Files.writeString(minesFolder.resolve("validmine.yml"), yamlContent); + Files.writeString(minesFolder.resolve("readme.txt"), "This is not a mine config"); + Files.writeString(minesFolder.resolve("backup.bak"), "Backup file"); + + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Load all mines + mines.loadAll(); + + // Only the .yml file should be loaded + assertEquals(1, mines.getAll().size()); + assertTrue(mines.find("validmine").isPresent()); + } + } + + @Test + void testLoadAllWithInvalidMine() throws IOException { + // Create mines folder + Path minesFolder = tempDir.resolve("mines"); + Files.createDirectories(minesFolder); + + // Create an invalid mine config file (missing required fields) + String invalidYaml = """ + selection.1: world;0;64;0;0.0;0.0 + """; + + Files.writeString(minesFolder.resolve("invalidmine.yml"), invalidYaml); + + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Load all mines - should not throw, but should log error + assertDoesNotThrow(() -> mines.loadAll()); + + // Invalid mine should not be loaded + assertEquals(0, mines.getAll().size()); + + // Verify error was logged + verify(plugin.loLogger(), atLeastOnce()).error(anyString()); + } + } + + @Test + void testCreateNewMine() throws IOException { + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a new mine + mines.create("newmine"); + + // Verify mine was created + assertEquals(1, mines.getAll().size()); + assertTrue(mines.find("newmine").isPresent()); + + Mine mine = mines.get("newmine"); + assertNotNull(mine); + assertEquals("newmine", mine.getName()); + + // Verify config file was created + Path configFile = tempDir.resolve("mines/newmine.yml"); + assertTrue(Files.exists(configFile)); + + // Verify logger was called + verify(plugin.loLogger()).info("Created mine: newmine"); + } + } + + @Test + void testCreateMineCreatesFolder() throws IOException { + // Ensure mines folder doesn't exist + Path minesFolder = tempDir.resolve("mines"); + assertFalse(Files.exists(minesFolder)); + + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a new mine + mines.create("testmine"); + + // Verify mines folder was created + assertTrue(Files.exists(minesFolder)); + assertTrue(Files.isDirectory(minesFolder)); + } + } + + @Test + void testCreateDuplicateMineThrowsException() throws IOException { + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a mine + mines.create("duplicate"); + + // Try to create the same mine again + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + mines.create("duplicate"); + }); + + assertTrue(exception.getMessage().contains("Mine already exists")); + } + } + + @Test + void testCreateMineIsCaseInsensitive() throws IOException { + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a mine with mixed case + mines.create("TestMine"); + + // Try to create the same mine with different case + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + mines.create("testmine"); + }); + + assertTrue(exception.getMessage().contains("Mine already exists")); + } + } + + @Test + void testGetMineByName() throws IOException { + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a mine + mines.create("testmine"); + + // Get mine by exact name + Mine mine = mines.get("testmine"); + assertNotNull(mine); + assertEquals("testmine", mine.getName()); + + // Get mine by different case + Mine mine2 = mines.get("TestMine"); + assertNotNull(mine2); + assertEquals("testmine", mine2.getName()); + + // Same mine instance + assertSame(mine, mine2); + } + } + + @Test + void testGetNonExistentMineThrowsException() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + mines.get("nonexistent"); + }); + + assertTrue(exception.getMessage().contains("Mine not found")); + } + + @Test + void testFindMineReturnsOptional() throws IOException { + // Mock Bukkit and Scheduler + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + + setupBukkitAndScheduler(bukkit, schedulerStatic); + + // Create a mine + mines.create("testmine"); + + // Find existing mine + assertTrue(mines.find("testmine").isPresent()); + + // Find non-existent mine + assertFalse(mines.find("nonexistent").isPresent()); + } + } +} diff --git a/src/test/java/com/loki/lomines/data/ConfigLoaderTest.java b/src/test/java/com/loki/lomines/data/ConfigLoaderTest.java new file mode 100644 index 0000000..2bdc1be --- /dev/null +++ b/src/test/java/com/loki/lomines/data/ConfigLoaderTest.java @@ -0,0 +1,537 @@ +package com.loki.lomines.data; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ConfigLoader. + * Tests parsing of YAML configuration files into MineConfig objects. + */ +class ConfigLoaderTest { + + private ConfigLoader configLoader; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + configLoader = new ConfigLoader(); + } + + @Test + void testLoadValidConfig() throws Exception { + // Create a valid YAML config file + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 50 + coal_ore: 30 + iron_ore: 20 + reset: + ticks: 6000 + percent: 10.0 + reset-on-percent: false + actionbar: + enabled: true + message: "Mine resetting soon" + range: 50.0 + timer-format: "mm:ss" + teleport-on-reset: false + reset-commands: + - "say Mine reset!" + broadcast-reset: "Mine has been reset" + random-rewards: + - chance: 1.0 + prevent-drops: false + blocks: + - diamond_ore + items: + - type: diamond + amount: 1 + commands: + - "eco give %player% 100" + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + // Mock Bukkit + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + // Load the config + MineConfig config = configLoader.load(configFile); + + // Verify selections + assertNotNull(config.getSelections()); + assertEquals(2, config.getSelections().size()); + + // Verify blocks + assertNotNull(config.getBlocks()); + assertEquals(3, config.getBlocks().size()); + assertTrue(config.getBlocks().containsKey("stone")); + assertTrue(config.getBlocks().containsKey("coal_ore")); + assertTrue(config.getBlocks().containsKey("iron_ore")); + assertEquals(50.0, config.getBlocks().get("stone")); + assertEquals(30.0, config.getBlocks().get("coal_ore")); + assertEquals(20.0, config.getBlocks().get("iron_ore")); + + // Verify rewards + assertNotNull(config.getRewards()); + assertEquals(1, config.getRewards().size()); + Reward reward = config.getRewards().get(0); + assertEquals(1.0, reward.getChance()); + assertFalse(reward.isPreventDrops()); + assertEquals(1, reward.getMaterials().size()); + assertTrue(reward.getMaterials().contains(Material.DIAMOND_ORE)); + + // Verify reset settings + assertEquals(6000, config.getResetTicks()); + assertEquals(10.0, config.getResetPercent()); + assertFalse(config.isResetOnPercentEnabled()); + + // Verify action bar settings + assertTrue(config.isActionBarEnabled()); + assertEquals("Mine resetting soon", config.getActionBarMessage()); + assertEquals(50.0, config.getActionBarRange()); + + // Verify other settings + assertEquals("mm:ss", config.getTimerFormat()); + assertFalse(config.isTeleportOnReset()); + assertNull(config.getTeleportLocation()); + assertEquals(1, config.getResetCommands().size()); + assertEquals("say Mine reset!", config.getResetCommands().get(0)); + assertEquals("Mine has been reset", config.getBroadcastReset()); + } + } + + @Test + void testLoadMissingFile() { + Path nonExistentFile = tempDir.resolve("nonexistent.yml"); + + assertThrows(IOException.class, () -> { + configLoader.load(nonExistentFile); + }); + } + + @Test + void testLoadNullPath() { + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(null); + }); + + assertTrue(exception.getMessage().contains("cannot be null")); + } + + @Test + void testLoadMissingSelections() throws Exception { + String yamlContent = """ + contents: + stone: 100 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("No selection coordinates found")); + } + + @Test + void testLoadOddNumberOfSelections() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + selection.3: world;20;64;20;0.0;0.0 + contents: + stone: 100 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("must be in pairs")); + } + } + + @Test + void testLoadMissingContents() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("Missing required 'contents' section")); + } + } + + @Test + void testLoadInvalidMaterial() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + invalid_material: 100 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("Unknown material")); + } + } + + @Test + void testLoadInvalidBlockWeight() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: -10 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("must be positive")); + } + } + + @Test + void testLoadInvalidRewardChance() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 100 + random-rewards: + - chance: 150 + blocks: + - diamond_ore + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + ConfigParseException exception = assertThrows(ConfigParseException.class, () -> { + configLoader.load(configFile); + }); + + assertTrue(exception.getMessage().contains("between 0 and 100")); + } + } + + @Test + void testLoadWithTeleportLocation() throws Exception { + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 100 + teleport-on-reset: true + teleport-location: world;5;70;5;90.0;0.0 + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + MineConfig config = configLoader.load(configFile); + + assertTrue(config.isTeleportOnReset()); + assertNotNull(config.getTeleportLocation()); + assertEquals(5.0, config.getTeleportLocation().getX()); + assertEquals(70.0, config.getTeleportLocation().getY()); + assertEquals(5.0, config.getTeleportLocation().getZ()); + } + } + + + @Test + void testSaveAndLoadRoundTrip() throws Exception { + // Create a config file to load (without items to avoid ItemStack initialization issues) + String yamlContent = """ + selection.1: world;0;64;0;0.0;0.0 + selection.2: world;10;74;10;0.0;0.0 + contents: + stone: 50 + coal_ore: 30 + reset: + ticks: 6000 + percent: 10.0 + reset-on-percent: false + actionbar: + enabled: true + message: "Mine resetting soon" + range: 50.0 + timer-format: "mm:ss" + teleport-on-reset: false + reset-commands: + - "say Mine reset!" + broadcast-reset: "Mine has been reset" + random-rewards: + - chance: 1.0 + prevent-drops: false + blocks: + - diamond_ore + commands: + - "eco give %player% 100" + """; + + Path configFile = tempDir.resolve("test-mine.yml"); + Files.writeString(configFile, yamlContent); + + // Mock Bukkit + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + // Load the config + MineConfig config = configLoader.load(configFile); + + // Save to a new file + Path savedFile = tempDir.resolve("saved-mine.yml"); + configLoader.save(config, savedFile); + + // Verify the file was created + assertTrue(Files.exists(savedFile)); + + // Verify the content is valid YAML + String savedContent = Files.readString(savedFile); + assertFalse(savedContent.isEmpty()); + + // Load the saved config + MineConfig loadedConfig = configLoader.load(savedFile); + + // Verify key properties match + assertEquals(config.getResetTicks(), loadedConfig.getResetTicks()); + assertEquals(config.getResetPercent(), loadedConfig.getResetPercent()); + assertEquals(config.isResetOnPercentEnabled(), loadedConfig.isResetOnPercentEnabled()); + assertEquals(config.isActionBarEnabled(), loadedConfig.isActionBarEnabled()); + assertEquals(config.getActionBarMessage(), loadedConfig.getActionBarMessage()); + assertEquals(config.getActionBarRange(), loadedConfig.getActionBarRange()); + assertEquals(config.getTimerFormat(), loadedConfig.getTimerFormat()); + assertEquals(config.isTeleportOnReset(), loadedConfig.isTeleportOnReset()); + assertEquals(config.getBroadcastReset(), loadedConfig.getBroadcastReset()); + assertEquals(config.getResetCommands().size(), loadedConfig.getResetCommands().size()); + assertEquals(config.getRewards().size(), loadedConfig.getRewards().size()); + } + } + + @Test + void testSaveNullConfig() { + Path configFile = tempDir.resolve("test.yml"); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + configLoader.save(null, configFile); + }); + + assertTrue(exception.getMessage().contains("Config cannot be null")); + } + + @Test + void testSaveNullPath() throws Exception { + // Create a minimal config + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + Location loc1 = new Location(world, 0, 64, 0, 0, 0); + Location loc2 = new Location(world, 10, 74, 10, 0, 0); + + MineConfig config = MineConfig.builder() + .selections(List.of(loc1, loc2)) + .blocks(Map.of("stone", 100.0)) + .rewards(List.of()) + .build(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + configLoader.save(config, null); + }); + + assertTrue(exception.getMessage().contains("Config file path cannot be null")); + } + } + + @Test + void testSaveWithTeleportLocation() throws Exception { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + Location loc1 = new Location(world, 0, 64, 0, 0, 0); + Location loc2 = new Location(world, 10, 74, 10, 0, 0); + Location teleportLoc = new Location(world, 5, 70, 5, 90, 0); + + MineConfig config = MineConfig.builder() + .selections(List.of(loc1, loc2)) + .blocks(Map.of("stone", 100.0)) + .rewards(List.of()) + .teleportOnReset(true) + .teleportLocation(teleportLoc) + .build(); + + Path savedFile = tempDir.resolve("with-teleport.yml"); + configLoader.save(config, savedFile); + + // Verify the file was created + assertTrue(Files.exists(savedFile)); + + // Load and verify + MineConfig loadedConfig = configLoader.load(savedFile); + assertTrue(loadedConfig.isTeleportOnReset()); + assertNotNull(loadedConfig.getTeleportLocation()); + assertEquals(5.0, loadedConfig.getTeleportLocation().getX()); + assertEquals(70.0, loadedConfig.getTeleportLocation().getY()); + assertEquals(5.0, loadedConfig.getTeleportLocation().getZ()); + } + } + + @Test + void testSaveEmptyRewards() throws Exception { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + Server server = mock(Server.class); + World world = mock(World.class); + + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + Location loc1 = new Location(world, 0, 64, 0, 0, 0); + Location loc2 = new Location(world, 10, 74, 10, 0, 0); + + MineConfig config = MineConfig.builder() + .selections(List.of(loc1, loc2)) + .blocks(Map.of("stone", 100.0)) + .rewards(List.of()) + .build(); + + Path savedFile = tempDir.resolve("no-rewards.yml"); + configLoader.save(config, savedFile); + + // Verify the file was created + assertTrue(Files.exists(savedFile)); + + // Load and verify + MineConfig loadedConfig = configLoader.load(savedFile); + assertTrue(loadedConfig.getRewards().isEmpty()); + } + } + +} diff --git a/src/test/java/com/loki/lomines/data/LeaderboardCacheIntegrationTest.java b/src/test/java/com/loki/lomines/data/LeaderboardCacheIntegrationTest.java new file mode 100644 index 0000000..b52d7ff --- /dev/null +++ b/src/test/java/com/loki/lomines/data/LeaderboardCacheIntegrationTest.java @@ -0,0 +1,154 @@ +package com.loki.lomines.data; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.core.LoLogger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Integration tests for Leaderboard caching with StatsManager. + * Verifies that cache invalidation works correctly when statistics change. + */ +class LeaderboardCacheIntegrationTest { + + private StatsManager statsManager; + private Leaderboard leaderboard; + + @BeforeEach + void setUp() { + // Create a mock plugin + LoMinesPlugin mockPlugin = mock(LoMinesPlugin.class); + LoLogger loLogger = mock(LoLogger.class); + File mockDataFolder = new File(System.getProperty("java.io.tmpdir"), "lomines-test"); + when(mockPlugin.getDataFolder()).thenReturn(mockDataFolder); + when(mockPlugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("TestLogger")); + when(mockPlugin.loLogger()).thenReturn(loLogger); + + statsManager = new StatsManager(mockPlugin); + leaderboard = statsManager.getLeaderboard(); + } + + @Test + void testCacheInvalidatedOnIncrementBlocks() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + // Set initial stats + statsManager.getOrCreate(player1).setTotalBlocks(100); + + // Build cache + List top1 = leaderboard.getTopTotal(10); + assertEquals(1, top1.size(), "Should have 1 player initially"); + assertEquals(player1, top1.get(0).getPlayerId()); + + // Increment blocks for player2 - this should invalidate cache + statsManager.incrementBlocks(player2, "mine1"); + + // Get top again - should rebuild cache with new player + List top2 = leaderboard.getTopTotal(10); + assertEquals(2, top2.size(), "Should have 2 players after increment"); + + // Verify order (player1 has 100, player2 has 1) + assertEquals(player1, top2.get(0).getPlayerId(), "Player1 should still be first"); + assertEquals(player2, top2.get(1).getPlayerId(), "Player2 should be second"); + } + + @Test + void testCacheRebuildsWithCorrectOrder() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + // Set initial stats + statsManager.getOrCreate(player1).setTotalBlocks(50); + statsManager.getOrCreate(player2).setTotalBlocks(100); + + // Build cache + List top1 = leaderboard.getTopTotal(10); + assertEquals(player2, top1.get(0).getPlayerId(), "Player2 should be first initially"); + + // Increment player1 many times to overtake player2 + for (int i = 0; i < 60; i++) { + statsManager.incrementBlocks(player1, "mine1"); + } + + // Get top again - cache should be rebuilt with new order + List top2 = leaderboard.getTopTotal(10); + assertEquals(player1, top2.get(0).getPlayerId(), "Player1 should be first after increments"); + assertEquals(110, top2.get(0).getCount(), "Player1 should have 110 blocks"); + assertEquals(player2, top2.get(1).getPlayerId(), "Player2 should be second"); + assertEquals(100, top2.get(1).getCount(), "Player2 should still have 100 blocks"); + } + + @Test + void testMultipleIncrementsInvalidateCache() { + UUID player1 = UUID.randomUUID(); + + // Build empty cache + List top1 = leaderboard.getTopTotal(10); + assertTrue(top1.isEmpty(), "Should be empty initially"); + + // Increment blocks multiple times + for (int i = 0; i < 5; i++) { + statsManager.incrementBlocks(player1, "mine1"); + } + + // Get top - should show updated count + List top2 = leaderboard.getTopTotal(10); + assertEquals(1, top2.size()); + assertEquals(5, top2.get(0).getCount(), "Should have 5 blocks after 5 increments"); + } + + @Test + void testCachePerformance() { + // Add many players + for (int i = 0; i < 100; i++) { + UUID playerId = UUID.randomUUID(); + statsManager.getOrCreate(playerId).setTotalBlocks(i + 1); + } + + // First call - builds cache (slower) + long start1 = System.nanoTime(); + List top1 = leaderboard.getTopTotal(10); + long time1 = System.nanoTime() - start1; + + // Second call - uses cache (should be much faster) + long start2 = System.nanoTime(); + List top2 = leaderboard.getTopTotal(10); + long time2 = System.nanoTime() - start2; + + assertEquals(10, top1.size()); + assertEquals(10, top2.size()); + + // Cache hit should be significantly faster (at least 2x) + assertTrue(time2 < time1, + String.format("Cache hit (%d ns) should be faster than cache miss (%d ns)", time2, time1)); + } + + @Test + void testDifferentLimitsUseSameCache() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + UUID player3 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setTotalBlocks(100); + statsManager.getOrCreate(player2).setTotalBlocks(200); + statsManager.getOrCreate(player3).setTotalBlocks(300); + + // Build cache with limit 10 + List top10 = leaderboard.getTopTotal(10); + assertEquals(3, top10.size()); + + // Get with limit 2 - should use same cache + List top2 = leaderboard.getTopTotal(2); + assertEquals(2, top2.size()); + assertEquals(player3, top2.get(0).getPlayerId(), "Should have highest player"); + assertEquals(player2, top2.get(1).getPlayerId(), "Should have second highest player"); + } +} diff --git a/src/test/java/com/loki/lomines/data/LeaderboardTest.java b/src/test/java/com/loki/lomines/data/LeaderboardTest.java new file mode 100644 index 0000000..93036b2 --- /dev/null +++ b/src/test/java/com/loki/lomines/data/LeaderboardTest.java @@ -0,0 +1,204 @@ +package com.loki.lomines.data; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.core.LoLogger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for Leaderboard class. + * Tests caching behavior, sorting, and filtering. + */ +class LeaderboardTest { + + private StatsManager statsManager; + private Leaderboard leaderboard; + + @BeforeEach + void setUp() { + // Create a mock plugin + LoMinesPlugin mockPlugin = mock(LoMinesPlugin.class); + LoLogger loLogger = mock(LoLogger.class); + File mockDataFolder = new File(System.getProperty("java.io.tmpdir"), "lomines-test"); + when(mockPlugin.getDataFolder()).thenReturn(mockDataFolder); + when(mockPlugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("TestLogger")); + when(mockPlugin.loLogger()).thenReturn(loLogger); + + statsManager = new StatsManager(mockPlugin); + leaderboard = statsManager.getLeaderboard(); + } + + @Test + void testGetTopTotal_EmptyStats() { + List top = leaderboard.getTopTotal(10); + assertTrue(top.isEmpty(), "Top list should be empty when no stats exist"); + } + + @Test + void testGetTopTotal_SinglePlayer() { + UUID playerId = UUID.randomUUID(); + PlayerStats stats = statsManager.getOrCreate(playerId); + stats.setTotalBlocks(100); + + List top = leaderboard.getTopTotal(10); + assertEquals(1, top.size()); + assertEquals(playerId, top.get(0).getPlayerId()); + assertEquals(100, top.get(0).getCount()); + } + + @Test + void testGetTopTotal_SortedDescending() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + UUID player3 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setTotalBlocks(50); + statsManager.getOrCreate(player2).setTotalBlocks(200); + statsManager.getOrCreate(player3).setTotalBlocks(100); + + List top = leaderboard.getTopTotal(10); + assertEquals(3, top.size()); + assertEquals(player2, top.get(0).getPlayerId()); // 200 blocks + assertEquals(player3, top.get(1).getPlayerId()); // 100 blocks + assertEquals(player1, top.get(2).getPlayerId()); // 50 blocks + } + + @Test + void testGetTopTotal_LimitRespected() { + for (int i = 0; i < 10; i++) { + UUID playerId = UUID.randomUUID(); + statsManager.getOrCreate(playerId).setTotalBlocks(i + 1); + } + + List top = leaderboard.getTopTotal(5); + assertEquals(5, top.size(), "Should respect the limit parameter"); + } + + @Test + void testGetTopTotal_FiltersZeroBlocks() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setTotalBlocks(100); + statsManager.getOrCreate(player2).setTotalBlocks(0); + + List top = leaderboard.getTopTotal(10); + assertEquals(1, top.size(), "Should filter out players with 0 blocks"); + assertEquals(player1, top.get(0).getPlayerId()); + } + + @Test + void testGetTopTotal_CachingWorks() { + UUID playerId = UUID.randomUUID(); + statsManager.getOrCreate(playerId).setTotalBlocks(100); + + // First call - builds cache + List top1 = leaderboard.getTopTotal(10); + + // Second call - should use cache (same reference) + List top2 = leaderboard.getTopTotal(10); + + assertEquals(top1.size(), top2.size()); + assertEquals(top1.get(0).getPlayerId(), top2.get(0).getPlayerId()); + } + + @Test + void testInvalidateCache_ClearsCache() { + UUID playerId = UUID.randomUUID(); + statsManager.getOrCreate(playerId).setTotalBlocks(100); + + // Build cache + leaderboard.getTopTotal(10); + + // Invalidate cache + leaderboard.invalidateCache(); + + // Add new player + UUID player2 = UUID.randomUUID(); + statsManager.getOrCreate(player2).setTotalBlocks(200); + + // Should rebuild cache with new player + List top = leaderboard.getTopTotal(10); + assertEquals(2, top.size(), "Cache should be rebuilt after invalidation"); + assertEquals(player2, top.get(0).getPlayerId(), "New player should be first"); + } + + @Test + void testGetTopByMine_SortedDescending() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + UUID player3 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setMineBlocks("mine1", 50); + statsManager.getOrCreate(player2).setMineBlocks("mine1", 200); + statsManager.getOrCreate(player3).setMineBlocks("mine1", 100); + + List top = leaderboard.getTopByMine("mine1", 10); + assertEquals(3, top.size()); + assertEquals(player2, top.get(0).getPlayerId()); // 200 blocks + assertEquals(player3, top.get(1).getPlayerId()); // 100 blocks + assertEquals(player1, top.get(2).getPlayerId()); // 50 blocks + } + + @Test + void testGetTopByMine_FiltersZeroBlocks() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setMineBlocks("mine1", 100); + statsManager.getOrCreate(player2).setMineBlocks("mine1", 0); + + List top = leaderboard.getTopByMine("mine1", 10); + assertEquals(1, top.size(), "Should filter out players with 0 blocks"); + assertEquals(player1, top.get(0).getPlayerId()); + } + + @Test + void testGetTopByMine_OnlyIncludesSpecificMine() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setMineBlocks("mine1", 100); + statsManager.getOrCreate(player2).setMineBlocks("mine2", 200); + + List top = leaderboard.getTopByMine("mine1", 10); + assertEquals(1, top.size(), "Should only include players with blocks in specified mine"); + assertEquals(player1, top.get(0).getPlayerId()); + } + + @Test + void testGetPosition_ReturnsCorrectPosition() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + UUID player3 = UUID.randomUUID(); + + statsManager.getOrCreate(player1).setTotalBlocks(50); + statsManager.getOrCreate(player2).setTotalBlocks(200); + statsManager.getOrCreate(player3).setTotalBlocks(100); + + assertEquals(1, leaderboard.getPosition(player2), "Player with most blocks should be position 1"); + assertEquals(2, leaderboard.getPosition(player3), "Player with second most blocks should be position 2"); + assertEquals(3, leaderboard.getPosition(player1), "Player with least blocks should be position 3"); + } + + @Test + void testGetPosition_ReturnsMinusOneForNonExistentPlayer() { + UUID playerId = UUID.randomUUID(); + assertEquals(-1, leaderboard.getPosition(playerId), "Should return -1 for non-existent player"); + } + + @Test + void testGetPosition_ReturnsMinusOneForZeroBlocks() { + UUID playerId = UUID.randomUUID(); + statsManager.getOrCreate(playerId).setTotalBlocks(0); + + assertEquals(-1, leaderboard.getPosition(playerId), "Should return -1 for player with 0 blocks"); + } +} diff --git a/src/test/java/com/loki/lomines/data/StatsManagerTest.java b/src/test/java/com/loki/lomines/data/StatsManagerTest.java new file mode 100644 index 0000000..7d4dbf6 --- /dev/null +++ b/src/test/java/com/loki/lomines/data/StatsManagerTest.java @@ -0,0 +1,261 @@ +package com.loki.lomines.data; + +import com.loki.lomines.LoMinesPlugin; +import dev.lolib.core.LoLogger; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for StatsManager. + * Tests loading, saving, and auto-saving of player statistics. + */ +class StatsManagerTest { + + private StatsManager statsManager; + private LoMinesPlugin plugin; + private Scheduler scheduler; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + plugin = mock(LoMinesPlugin.class); + LoLogger loLogger = mock(LoLogger.class); + scheduler = mock(Scheduler.class); + + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("TestLogger")); + when(plugin.loLogger()).thenReturn(loLogger); + + statsManager = new StatsManager(plugin); + } + + @Test + void testGetOrCreate() { + UUID playerId = UUID.randomUUID(); + + PlayerStats stats1 = statsManager.getOrCreate(playerId); + assertNotNull(stats1); + assertEquals(playerId, stats1.getPlayerId()); + + // Should return the same instance + PlayerStats stats2 = statsManager.getOrCreate(playerId); + assertSame(stats1, stats2); + } + + @Test + void testIncrementBlocks() { + UUID playerId = UUID.randomUUID(); + String mineName = "TestMine"; + + statsManager.incrementBlocks(playerId, mineName); + + PlayerStats stats = statsManager.getOrCreate(playerId); + assertEquals(1, stats.getTotalBlocks()); + assertEquals(1, stats.getMineBlocks(mineName)); + + // Increment again + statsManager.incrementBlocks(playerId, mineName); + assertEquals(2, stats.getTotalBlocks()); + assertEquals(2, stats.getMineBlocks(mineName)); + } + + @Test + void testIncrementBlocksMultipleMines() { + UUID playerId = UUID.randomUUID(); + + statsManager.incrementBlocks(playerId, "Mine1"); + statsManager.incrementBlocks(playerId, "Mine2"); + statsManager.incrementBlocks(playerId, "Mine1"); + + PlayerStats stats = statsManager.getOrCreate(playerId); + assertEquals(3, stats.getTotalBlocks()); + assertEquals(2, stats.getMineBlocks("Mine1")); + assertEquals(1, stats.getMineBlocks("Mine2")); + } + + @Test + void testSaveEmptyStats() throws IOException { + statsManager.save(); + + Path statsFile = tempDir.resolve("stats.yml"); + assertTrue(Files.exists(statsFile)); + + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(statsFile.toFile()); + assertNull(yaml.getConfigurationSection("players")); + } + + @Test + void testSaveWithStats() throws IOException { + UUID playerId = UUID.randomUUID(); + statsManager.incrementBlocks(playerId, "TestMine"); + statsManager.incrementBlocks(playerId, "TestMine"); + statsManager.incrementBlocks(playerId, "AnotherMine"); + + statsManager.save(); + + Path statsFile = tempDir.resolve("stats.yml"); + assertTrue(Files.exists(statsFile)); + + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(statsFile.toFile()); + assertNotNull(yaml.getConfigurationSection("players")); + + String playerPath = "players." + playerId.toString(); + assertEquals(3, yaml.getLong(playerPath + ".total")); + assertEquals(2, yaml.getLong(playerPath + ".mines.testmine")); + assertEquals(1, yaml.getLong(playerPath + ".mines.anothermine")); + } + + @Test + void testLoadNonExistentFile() throws IOException { + // Should not throw exception, just log info + statsManager.load(); + + // Stats should be empty + assertTrue(statsManager.getAllStats().isEmpty()); + } + + @Test + void testLoadEmptyFile() throws IOException { + Path statsFile = tempDir.resolve("stats.yml"); + Files.writeString(statsFile, ""); + + statsManager.load(); + + assertTrue(statsManager.getAllStats().isEmpty()); + } + + @Test + void testLoadValidStats() throws IOException { + UUID playerId = UUID.randomUUID(); + String yamlContent = String.format(""" + players: + %s: + total: 100 + mines: + mine1: 60 + mine2: 40 + """, playerId.toString()); + + Path statsFile = tempDir.resolve("stats.yml"); + Files.writeString(statsFile, yamlContent); + + statsManager.load(); + + PlayerStats stats = statsManager.getOrCreate(playerId); + assertEquals(100, stats.getTotalBlocks()); + assertEquals(60, stats.getMineBlocks("mine1")); + assertEquals(40, stats.getMineBlocks("mine2")); + } + + @Test + void testLoadInvalidUUID() throws IOException { + String yamlContent = """ + players: + invalid-uuid: + total: 100 + """; + + Path statsFile = tempDir.resolve("stats.yml"); + Files.writeString(statsFile, yamlContent); + + // Should not throw exception, just log warning + statsManager.load(); + + assertTrue(statsManager.getAllStats().isEmpty()); + } + + @Test + void testSaveAndLoadRoundTrip() throws IOException { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + statsManager.incrementBlocks(player1, "Mine1"); + statsManager.incrementBlocks(player1, "Mine1"); + statsManager.incrementBlocks(player1, "Mine2"); + statsManager.incrementBlocks(player2, "Mine1"); + + statsManager.save(); + + // Create new manager and load + StatsManager newManager = new StatsManager(plugin); + newManager.load(); + + PlayerStats stats1 = newManager.getOrCreate(player1); + assertEquals(3, stats1.getTotalBlocks()); + assertEquals(2, stats1.getMineBlocks("Mine1")); + assertEquals(1, stats1.getMineBlocks("Mine2")); + + PlayerStats stats2 = newManager.getOrCreate(player2); + assertEquals(1, stats2.getTotalBlocks()); + assertEquals(1, stats2.getMineBlocks("Mine1")); + } + + @Test + void testStartAutoSave() { + try (MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + ScheduledTask task = mock(ScheduledTask.class); + + schedulerStatic.when(() -> Scheduler.get(plugin)).thenReturn(scheduler); + when(scheduler.runTimerAsync(any(Runnable.class), anyLong(), anyLong())).thenReturn(task); + + statsManager.startAutoSave(); + + // Verify that runTimerAsync was called with correct interval (5 minutes = 6000 ticks) + verify(scheduler).runTimerAsync(any(Runnable.class), eq(6000L), eq(6000L)); + } + } + + @Test + void testStopAutoSave() { + try (MockedStatic schedulerStatic = mockStatic(Scheduler.class)) { + ScheduledTask task = mock(ScheduledTask.class); + + schedulerStatic.when(() -> Scheduler.get(plugin)).thenReturn(scheduler); + when(scheduler.runTimerAsync(any(Runnable.class), anyLong(), anyLong())).thenReturn(task); + + statsManager.startAutoSave(); + statsManager.stopAutoSave(); + + verify(task).cancel(); + } + } + + @Test + void testStopAutoSaveWithoutStart() { + // Should not throw exception + assertDoesNotThrow(() -> statsManager.stopAutoSave()); + } + + @Test + void testGetAllStats() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + statsManager.incrementBlocks(player1, "Mine1"); + statsManager.incrementBlocks(player2, "Mine2"); + + assertEquals(2, statsManager.getAllStats().size()); + } + + @Test + void testGetLeaderboard() { + assertNotNull(statsManager.getLeaderboard()); + } +} diff --git a/src/test/java/com/loki/lomines/util/ChunkUtilsTest.java b/src/test/java/com/loki/lomines/util/ChunkUtilsTest.java new file mode 100644 index 0000000..19460a6 --- /dev/null +++ b/src/test/java/com/loki/lomines/util/ChunkUtilsTest.java @@ -0,0 +1,155 @@ +package com.loki.lomines.util; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ChunkUtils. + * Tests Paper detection and chunk update functionality. + */ +class ChunkUtilsTest { + + @Mock + private World world; + + @Mock + private Chunk chunk; + + @Mock + private Player player; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testUpdateChunks_withNullRegion_throwsException() { + assertThrows(IllegalArgumentException.class, () -> { + ChunkUtils.updateChunks(null); + }); + } + + @Test + void testUpdateChunks_withSingleChunkRegion() { + // Create a small region within a single chunk + Location loc1 = new Location(world, 0, 64, 0); + Location loc2 = new Location(world, 5, 70, 5); + Cuboid region = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.singletonList(player)); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(0); + when(chunk.getZ()).thenReturn(0); + + // Should not throw exception + assertDoesNotThrow(() -> ChunkUtils.updateChunks(region)); + + // Verify chunk was retrieved + verify(world, atLeastOnce()).getChunkAt(0, 0); + } + + @Test + void testUpdateChunks_withMultipleChunks() { + // Create a region spanning multiple chunks (16 blocks per chunk) + Location loc1 = new Location(world, 0, 64, 0); + Location loc2 = new Location(world, 32, 70, 32); + Cuboid region = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.singletonList(player)); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(0); + when(chunk.getZ()).thenReturn(0); + + assertDoesNotThrow(() -> ChunkUtils.updateChunks(region)); + + // Should retrieve multiple chunks (0,0), (0,1), (0,2), (1,0), (1,1), (1,2), (2,0), (2,1), (2,2) + // That's 9 chunks total for a 3x3 chunk area + verify(world, atLeast(9)).getChunkAt(anyInt(), anyInt()); + } + + @Test + void testUpdateChunks_withNegativeCoordinates() { + // Test with negative coordinates + Location loc1 = new Location(world, -16, 64, -16); + Location loc2 = new Location(world, -1, 70, -1); + Cuboid region = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.singletonList(player)); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(-1); + when(chunk.getZ()).thenReturn(-1); + + assertDoesNotThrow(() -> ChunkUtils.updateChunks(region)); + + // Verify chunk was retrieved for negative coordinates + verify(world, atLeastOnce()).getChunkAt(-1, -1); + } + + @Test + void testUpdateChunks_withLargeRegion() { + // Test with a large region spanning many chunks + Location loc1 = new Location(world, 0, 0, 0); + Location loc2 = new Location(world, 100, 100, 100); + Cuboid region = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.emptyList()); + when(chunk.getWorld()).thenReturn(world); + + assertDoesNotThrow(() -> ChunkUtils.updateChunks(region)); + + // Should handle large regions without issues + // 100 blocks = ~7 chunks per axis, so ~49 chunks total + verify(world, atLeast(40)).getChunkAt(anyInt(), anyInt()); + } + + @Test + void testUpdateChunks_withNoPlayers() { + // Test behavior when no players are online + Location loc1 = new Location(world, 0, 64, 0); + Location loc2 = new Location(world, 5, 70, 5); + Cuboid region = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.emptyList()); + when(chunk.getWorld()).thenReturn(world); + + // Should not throw exception even with no players + assertDoesNotThrow(() -> ChunkUtils.updateChunks(region)); + } + + @Test + void testChunkCoordinateCalculation() { + // Test that chunk coordinates are calculated correctly + // Block 0-15 = chunk 0, block 16-31 = chunk 1, etc. + + Location loc1 = new Location(world, 0, 64, 0); + Location loc2 = new Location(world, 15, 70, 15); + Cuboid singleChunk = new Cuboid(loc1, loc2); + + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getPlayers()).thenReturn(Collections.emptyList()); + when(chunk.getWorld()).thenReturn(world); + + ChunkUtils.updateChunks(singleChunk); + + // Should only access chunk (0,0) + verify(world, times(1)).getChunkAt(0, 0); + } +} diff --git a/src/test/java/com/loki/lomines/util/LocationParserTest.java b/src/test/java/com/loki/lomines/util/LocationParserTest.java new file mode 100644 index 0000000..ebfa12f --- /dev/null +++ b/src/test/java/com/loki/lomines/util/LocationParserTest.java @@ -0,0 +1,179 @@ +package com.loki.lomines.util; + +import com.loki.lomines.data.ConfigParseException; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LocationParserTest { + + @Test + void testParseValidLocation() throws ConfigParseException { + try (MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + World mockWorld = mock(World.class); + when(mockWorld.getName()).thenReturn("world"); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mockWorld); + + Location location = LocationParser.parse("world;100.5;64.0;-200.75;90.0;45.0"); + + assertNotNull(location); + assertEquals(mockWorld, location.getWorld()); + assertEquals(100.5, location.getX(), 0.001); + assertEquals(64.0, location.getY(), 0.001); + assertEquals(-200.75, location.getZ(), 0.001); + assertEquals(90.0f, location.getYaw(), 0.001); + assertEquals(45.0f, location.getPitch(), 0.001); + } + } + + @Test + void testParseIntegerCoordinates() throws ConfigParseException { + try (MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + World mockWorld = mock(World.class); + when(mockWorld.getName()).thenReturn("world_nether"); + bukkit.when(() -> Bukkit.getWorld("world_nether")).thenReturn(mockWorld); + + Location location = LocationParser.parse("world_nether;0;128;0;0;0"); + + assertNotNull(location); + assertEquals(0.0, location.getX(), 0.001); + assertEquals(128.0, location.getY(), 0.001); + assertEquals(0.0, location.getZ(), 0.001); + } + } + + @Test + void testParseNegativeCoordinates() throws ConfigParseException { + try (MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + World mockWorld = mock(World.class); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mockWorld); + + Location location = LocationParser.parse("world;-100;-64;-200;-180;-90"); + + assertNotNull(location); + assertEquals(-100.0, location.getX(), 0.001); + assertEquals(-64.0, location.getY(), 0.001); + assertEquals(-200.0, location.getZ(), 0.001); + assertEquals(-180.0f, location.getYaw(), 0.001); + assertEquals(-90.0f, location.getPitch(), 0.001); + } + } + + @Test + void testParseNullString() { + ConfigParseException exception = assertThrows( + ConfigParseException.class, + () -> LocationParser.parse(null) + ); + assertTrue(exception.getMessage().contains("cannot be null or empty")); + } + + @Test + void testParseEmptyString() { + ConfigParseException exception = assertThrows( + ConfigParseException.class, + () -> LocationParser.parse("") + ); + assertTrue(exception.getMessage().contains("cannot be null or empty")); + } + + @Test + void testParseInvalidFormat() { + ConfigParseException exception = assertThrows( + ConfigParseException.class, + () -> LocationParser.parse("world;100;64") + ); + assertTrue(exception.getMessage().contains("Invalid location format")); + assertTrue(exception.getMessage().contains("Expected format")); + } + + @Test + void testParseInvalidNumbers() { + ConfigParseException exception = assertThrows( + ConfigParseException.class, + () -> LocationParser.parse("world;abc;64;0;0;0") + ); + assertTrue(exception.getMessage().contains("Invalid location format")); + } + + @Test + void testParseNonExistentWorld() { + try (MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getWorld("nonexistent")).thenReturn(null); + + ConfigParseException exception = assertThrows( + ConfigParseException.class, + () -> LocationParser.parse("nonexistent;0;64;0;0;0") + ); + assertTrue(exception.getMessage().contains("does not exist")); + assertTrue(exception.getMessage().contains("nonexistent")); + } + } + + @Test + void testFormatValidLocation() { + World mockWorld = mock(World.class); + when(mockWorld.getName()).thenReturn("world"); + + Location location = new Location(mockWorld, 100.5, 64.0, -200.75, 90.0f, 45.0f); + String formatted = LocationParser.format(location); + + assertEquals("world;100.50;64.00;-200.75;90.00;45.00", formatted); + } + + @Test + void testFormatIntegerCoordinates() { + World mockWorld = mock(World.class); + when(mockWorld.getName()).thenReturn("world_nether"); + + Location location = new Location(mockWorld, 0, 128, 0, 0, 0); + String formatted = LocationParser.format(location); + + assertEquals("world_nether;0.00;128.00;0.00;0.00;0.00", formatted); + } + + @Test + void testFormatNullLocation() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> LocationParser.format(null) + ); + assertTrue(exception.getMessage().contains("Location cannot be null")); + } + + @Test + void testFormatNullWorld() { + Location location = new Location(null, 0, 0, 0); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> LocationParser.format(location) + ); + assertTrue(exception.getMessage().contains("world cannot be null")); + } + + @Test + void testRoundTrip() throws ConfigParseException { + try (MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + World mockWorld = mock(World.class); + when(mockWorld.getName()).thenReturn("world"); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mockWorld); + + Location original = new Location(mockWorld, 100.5, 64.0, -200.75, 90.0f, 45.0f); + String formatted = LocationParser.format(original); + Location parsed = LocationParser.parse(formatted); + + assertEquals(original.getX(), parsed.getX(), 0.01); + assertEquals(original.getY(), parsed.getY(), 0.01); + assertEquals(original.getZ(), parsed.getZ(), 0.01); + assertEquals(original.getYaw(), parsed.getYaw(), 0.01); + assertEquals(original.getPitch(), parsed.getPitch(), 0.01); + } + } +} diff --git a/src/test/java/com/loki/lomines/util/SelectionManagerTest.java b/src/test/java/com/loki/lomines/util/SelectionManagerTest.java new file mode 100644 index 0000000..ac146c1 --- /dev/null +++ b/src/test/java/com/loki/lomines/util/SelectionManagerTest.java @@ -0,0 +1,128 @@ +package com.loki.lomines.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class SelectionManagerTest { + + private SelectionManager manager; + private UUID playerId; + + @BeforeEach + void setUp() { + manager = new SelectionManager(); + playerId = UUID.randomUUID(); + } + + @Test + void getSelection_createsNewSelectionIfNotExists() { + Selection selection = manager.getSelection(playerId); + + assertNotNull(selection); + assertTrue(manager.hasSelection(playerId)); + } + + @Test + void getSelection_returnsSameInstanceOnMultipleCalls() { + Selection first = manager.getSelection(playerId); + Selection second = manager.getSelection(playerId); + + assertSame(first, second); + } + + @Test + void getSelection_throwsExceptionForNullPlayerId() { + assertThrows(IllegalArgumentException.class, () -> { + manager.getSelection(null); + }); + } + + @Test + void findSelection_returnsEmptyWhenNotExists() { + Optional result = manager.findSelection(playerId); + + assertTrue(result.isEmpty()); + } + + @Test + void findSelection_returnsSelectionWhenExists() { + Selection created = manager.getSelection(playerId); + Optional found = manager.findSelection(playerId); + + assertTrue(found.isPresent()); + assertSame(created, found.get()); + } + + @Test + void findSelection_throwsExceptionForNullPlayerId() { + assertThrows(IllegalArgumentException.class, () -> { + manager.findSelection(null); + }); + } + + @Test + void hasSelection_returnsFalseWhenNotExists() { + assertFalse(manager.hasSelection(playerId)); + } + + @Test + void hasSelection_returnsTrueWhenExists() { + manager.getSelection(playerId); + + assertTrue(manager.hasSelection(playerId)); + } + + @Test + void hasSelection_throwsExceptionForNullPlayerId() { + assertThrows(IllegalArgumentException.class, () -> { + manager.hasSelection(null); + }); + } + + @Test + void clearSelection_removesSelection() { + manager.getSelection(playerId); + assertTrue(manager.hasSelection(playerId)); + + manager.clearSelection(playerId); + + assertFalse(manager.hasSelection(playerId)); + } + + @Test + void clearSelection_doesNothingWhenSelectionNotExists() { + assertDoesNotThrow(() -> { + manager.clearSelection(playerId); + }); + } + + @Test + void clearSelection_throwsExceptionForNullPlayerId() { + assertThrows(IllegalArgumentException.class, () -> { + manager.clearSelection(null); + }); + } + + @Test + void multiplePlayersHaveIndependentSelections() { + UUID player1 = UUID.randomUUID(); + UUID player2 = UUID.randomUUID(); + + Selection selection1 = manager.getSelection(player1); + Selection selection2 = manager.getSelection(player2); + + assertNotSame(selection1, selection2); + assertTrue(manager.hasSelection(player1)); + assertTrue(manager.hasSelection(player2)); + + manager.clearSelection(player1); + + assertFalse(manager.hasSelection(player1)); + assertTrue(manager.hasSelection(player2)); + } +} diff --git a/src/test/java/com/loki/lomines/util/SelectionTest.java b/src/test/java/com/loki/lomines/util/SelectionTest.java new file mode 100644 index 0000000..73f62e3 --- /dev/null +++ b/src/test/java/com/loki/lomines/util/SelectionTest.java @@ -0,0 +1,222 @@ +package com.loki.lomines.util; + +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SelectionTest { + + private World world; + private Selection selection; + + @BeforeEach + void setUp() { + world = mock(World.class); + selection = new Selection(); + when(world.getName()).thenReturn("world"); + } + + @Test + void testSetAndGetPoint() { + Location loc = new Location(world, 10, 64, 20); + + selection.setPoint(0, loc); + + Optional result = selection.getPoint(0); + assertTrue(result.isPresent()); + assertEquals(loc, result.get()); + } + + @Test + void testGetPointReturnsEmptyWhenNotSet() { + Optional result = selection.getPoint(5); + assertFalse(result.isPresent()); + } + + @Test + void testSetPointInvalidIndexThrows() { + Location loc = new Location(world, 10, 64, 20); + + assertThrows(IllegalArgumentException.class, () -> selection.setPoint(-1, loc)); + assertThrows(IllegalArgumentException.class, () -> selection.setPoint(10, loc)); + } + + @Test + void testGetPointInvalidIndexThrows() { + assertThrows(IllegalArgumentException.class, () -> selection.getPoint(-1)); + assertThrows(IllegalArgumentException.class, () -> selection.getPoint(10)); + } + + @Test + void testGetPair() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + + Location[] pair = selection.getPair(0); + assertEquals(2, pair.length); + assertEquals(loc1, pair[0]); + assertEquals(loc2, pair[1]); + } + + @Test + void testGetPairWithNulls() { + Location loc1 = new Location(world, 10, 64, 20); + + selection.setPoint(2, loc1); + + Location[] pair = selection.getPair(1); + assertEquals(2, pair.length); + assertEquals(loc1, pair[0]); + assertNull(pair[1]); + } + + @Test + void testGetPairInvalidIndexThrows() { + assertThrows(IllegalArgumentException.class, () -> selection.getPair(-1)); + assertThrows(IllegalArgumentException.class, () -> selection.getPair(5)); + } + + @Test + void testHasPairReturnsTrueWhenBothSet() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + + assertTrue(selection.hasPair(0)); + } + + @Test + void testHasPairReturnsFalseWhenOnlyOneSet() { + Location loc1 = new Location(world, 10, 64, 20); + + selection.setPoint(0, loc1); + + assertFalse(selection.hasPair(0)); + } + + @Test + void testHasPairReturnsFalseWhenNoneSet() { + assertFalse(selection.hasPair(0)); + } + + @Test + void testHasPairInvalidIndexThrows() { + assertThrows(IllegalArgumentException.class, () -> selection.hasPair(-1)); + assertThrows(IllegalArgumentException.class, () -> selection.hasPair(5)); + } + + @Test + void testClear() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + selection.setPoint(5, loc1); + + selection.clear(); + + assertFalse(selection.getPoint(0).isPresent()); + assertFalse(selection.getPoint(1).isPresent()); + assertFalse(selection.getPoint(5).isPresent()); + } + + @Test + void testToCuboidsWithNoPairs() { + List cuboids = selection.toCuboids(); + + assertTrue(cuboids.isEmpty()); + } + + @Test + void testToCuboidsWithOnePair() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + + List cuboids = selection.toCuboids(); + + assertEquals(1, cuboids.size()); + assertTrue(cuboids.get(0).contains(new Location(world, 15, 69, 25))); + } + + @Test + void testToCuboidsWithMultiplePairs() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + Location loc3 = new Location(world, 30, 64, 40); + Location loc4 = new Location(world, 40, 74, 50); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + selection.setPoint(2, loc3); + selection.setPoint(3, loc4); + + List cuboids = selection.toCuboids(); + + assertEquals(2, cuboids.size()); + } + + @Test + void testToCuboidsSkipsIncompletePairs() { + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + Location loc3 = new Location(world, 30, 64, 40); + + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + selection.setPoint(2, loc3); + // Point 3 not set + + List cuboids = selection.toCuboids(); + + assertEquals(1, cuboids.size()); + } + + @Test + void testPairInvariant() { + // Test that pairs are organized as 2i and 2i+1 + Location loc1 = new Location(world, 10, 64, 20); + Location loc2 = new Location(world, 20, 74, 30); + + // Pair 0: indices 0 and 1 + selection.setPoint(0, loc1); + selection.setPoint(1, loc2); + assertTrue(selection.hasPair(0)); + + // Pair 1: indices 2 and 3 + selection.setPoint(2, loc1); + selection.setPoint(3, loc2); + assertTrue(selection.hasPair(1)); + + // Pair 2: indices 4 and 5 + selection.setPoint(4, loc1); + selection.setPoint(5, loc2); + assertTrue(selection.hasPair(2)); + + // Pair 3: indices 6 and 7 + selection.setPoint(6, loc1); + selection.setPoint(7, loc2); + assertTrue(selection.hasPair(3)); + + // Pair 4: indices 8 and 9 + selection.setPoint(8, loc1); + selection.setPoint(9, loc2); + assertTrue(selection.hasPair(4)); + } +} diff --git a/src/test/java/com/loki/lomines/util/TimeFormatterTest.java b/src/test/java/com/loki/lomines/util/TimeFormatterTest.java new file mode 100644 index 0000000..d74462a --- /dev/null +++ b/src/test/java/com/loki/lomines/util/TimeFormatterTest.java @@ -0,0 +1,175 @@ +package com.loki.lomines.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TimeFormatterTest { + + @Test + void testFormatMinutesSeconds() { + // 1 minute = 60 seconds = 1200 ticks + assertEquals("01:00", TimeFormatter.format(1200, "mm:ss")); + + // 2 minutes 30 seconds = 150 seconds = 3000 ticks + assertEquals("02:30", TimeFormatter.format(3000, "mm:ss")); + + // 0 seconds + assertEquals("00:00", TimeFormatter.format(0, "mm:ss")); + + // 59 seconds = 1180 ticks + assertEquals("00:59", TimeFormatter.format(1180, "mm:ss")); + + // 10 minutes 5 seconds = 605 seconds = 12100 ticks + assertEquals("10:05", TimeFormatter.format(12100, "mm:ss")); + } + + @Test + void testFormatHoursMinutesSeconds() { + // 1 hour = 3600 seconds = 72000 ticks + assertEquals("01:00:00", TimeFormatter.format(72000, "hh:mm:ss")); + + // 1 hour 30 minutes 45 seconds = 5445 seconds = 108900 ticks + assertEquals("01:30:45", TimeFormatter.format(108900, "hh:mm:ss")); + + // 0 seconds + assertEquals("00:00:00", TimeFormatter.format(0, "hh:mm:ss")); + + // 2 hours 5 minutes 3 seconds = 7503 seconds = 150060 ticks + assertEquals("02:05:03", TimeFormatter.format(150060, "hh:mm:ss")); + + // 23 hours 59 minutes 59 seconds = 86399 seconds = 1727980 ticks + assertEquals("23:59:59", TimeFormatter.format(1727980, "hh:mm:ss")); + } + + @Test + void testFormatSeconds() { + // 100 ticks = 5 seconds + assertEquals("5", TimeFormatter.format(100, "s")); + + // 1200 ticks = 60 seconds + assertEquals("60", TimeFormatter.format(1200, "s")); + + // 0 ticks = 0 seconds + assertEquals("0", TimeFormatter.format(0, "s")); + + // 6000 ticks = 300 seconds (5 minutes) + assertEquals("300", TimeFormatter.format(6000, "s")); + } + + @Test + void testFormatMinutes() { + // 1200 ticks = 60 seconds = 1 minute + assertEquals("1", TimeFormatter.format(1200, "m")); + + // 6000 ticks = 300 seconds = 5 minutes + assertEquals("5", TimeFormatter.format(6000, "m")); + + // 0 ticks = 0 minutes + assertEquals("0", TimeFormatter.format(0, "m")); + + // 72000 ticks = 3600 seconds = 60 minutes + assertEquals("60", TimeFormatter.format(72000, "m")); + + // 1100 ticks = 55 seconds = 0 minutes (truncated) + assertEquals("0", TimeFormatter.format(1100, "m")); + } + + @Test + void testFormatHours() { + // 72000 ticks = 3600 seconds = 1 hour + assertEquals("1", TimeFormatter.format(72000, "h")); + + // 144000 ticks = 7200 seconds = 2 hours + assertEquals("2", TimeFormatter.format(144000, "h")); + + // 0 ticks = 0 hours + assertEquals("0", TimeFormatter.format(0, "h")); + + // 360000 ticks = 18000 seconds = 5 hours + assertEquals("5", TimeFormatter.format(360000, "h")); + + // 71999 ticks = 3599 seconds = 0 hours (truncated) + assertEquals("0", TimeFormatter.format(71999, "h")); + } + + @Test + void testFormatCaseInsensitive() { + assertEquals("01:00", TimeFormatter.format(1200, "MM:SS")); + assertEquals("01:00:00", TimeFormatter.format(72000, "HH:MM:SS")); + assertEquals("5", TimeFormatter.format(100, "S")); + assertEquals("1", TimeFormatter.format(1200, "M")); + assertEquals("1", TimeFormatter.format(72000, "H")); + } + + @Test + void testFormatNegativeTicks() { + // Negative ticks should be treated as 0 + assertEquals("00:00", TimeFormatter.format(-100, "mm:ss")); + assertEquals("00:00:00", TimeFormatter.format(-1000, "hh:mm:ss")); + assertEquals("0", TimeFormatter.format(-50, "s")); + } + + @Test + void testFormatNullFormat() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TimeFormatter.format(1200, null) + ); + assertTrue(exception.getMessage().contains("cannot be null or empty")); + } + + @Test + void testFormatEmptyFormat() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TimeFormatter.format(1200, "") + ); + assertTrue(exception.getMessage().contains("cannot be null or empty")); + } + + @Test + void testFormatWhitespaceFormat() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TimeFormatter.format(1200, " ") + ); + assertTrue(exception.getMessage().contains("cannot be null or empty")); + } + + @Test + void testFormatUnknownFormat() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TimeFormatter.format(1200, "invalid") + ); + assertTrue(exception.getMessage().contains("Unknown time format")); + assertTrue(exception.getMessage().contains("invalid")); + assertTrue(exception.getMessage().contains("Supported formats")); + } + + @Test + void testFormatLargeValues() { + // 1 million ticks = 50000 seconds = 833 minutes = 13 hours 53 minutes 20 seconds + assertEquals("833:20", TimeFormatter.format(1000000, "mm:ss")); + assertEquals("13:53:20", TimeFormatter.format(1000000, "hh:mm:ss")); + assertEquals("50000", TimeFormatter.format(1000000, "s")); + assertEquals("833", TimeFormatter.format(1000000, "m")); + assertEquals("13", TimeFormatter.format(1000000, "h")); + } + + @Test + void testFormatResetTimerExample() { + // Typical reset timer: 6000 ticks (5 minutes) + assertEquals("05:00", TimeFormatter.format(6000, "mm:ss")); + + // After 1 minute: 4800 ticks remaining + assertEquals("04:00", TimeFormatter.format(4800, "mm:ss")); + + // After 4 minutes 30 seconds: 600 ticks remaining + assertEquals("00:30", TimeFormatter.format(600, "mm:ss")); + + // Just before reset: 20 ticks remaining (1 second) + assertEquals("00:01", TimeFormatter.format(20, "mm:ss")); + } +}