commit bd32b9fab24d45a189eaa028a21e727c4028d3be Author: loki5512344 Date: Thu Apr 23 23:01:11 2026 +0200 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) 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 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..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")); + } +}