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)
This commit is contained in:
commit
bd32b9fab2
72 changed files with 8656 additions and 0 deletions
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
771
ARCHITECTURE.md
Normal file
771
ARCHITECTURE.md
Normal file
|
|
@ -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/<n>.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<Player> 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("<green>Добавить").build(),
|
||||
p -> openAddBlockMenu(p));
|
||||
// ... слоты с блоками
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ItemBuilder
|
||||
|
||||
```java
|
||||
import dev.lolib.items.ItemBuilder;
|
||||
|
||||
// Кнопка сброса
|
||||
ItemStack btn = ItemBuilder.of(Material.REDSTONE)
|
||||
.name("<red>Сбросить шахту")
|
||||
.lore("<gray>Заполнение: <white>" + percent + "%")
|
||||
.build();
|
||||
|
||||
// Заглушка
|
||||
ItemStack filler = ItemBuilder.of(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
|
||||
|
||||
// Палочка выделения
|
||||
ItemStack wand = ItemBuilder.of(Material.GOLDEN_AXE)
|
||||
.name("<#00AAFF><b>Палочка выделения")
|
||||
.lore("<#00AAFF><b>ЛКМ <gray>— позиция #1")
|
||||
.lore("<#00AAFF><b>ПКМ <gray>— позиция #2")
|
||||
.lore("<#00AAFF><b>Shift+ПКМ <gray>— выбор региона")
|
||||
.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<Cuboid> 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<String, Mine> 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<String, Mine> 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<String, AtomicLong> 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<Integer, Location>` = `(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/<n>.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\<Map\> | список наград |
|
||||
| `RESET_COMMANDS` | `reset-commands` | List\<String\> | команды после сброса |
|
||||
|
||||
### Структура блока награды
|
||||
|
||||
```yaml
|
||||
- chance: 0.01
|
||||
prevent-drops: false
|
||||
blocks:
|
||||
- "diamond_ore"
|
||||
items:
|
||||
- type: diamond
|
||||
amount: 1
|
||||
name: "<gold>Редкий алмаз"
|
||||
commands:
|
||||
- "eco give <player> 500"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Интеграции
|
||||
|
||||
### PlaceholderAPI
|
||||
|
||||
| Плейсхолдер | Возвращает |
|
||||
|---|---|
|
||||
| `%lomines_percent_<mine>%` | `blocks/volume*100` (2 знака) |
|
||||
| `%lomines_notbroken_<mine>%` | `mine.blocks` |
|
||||
| `%lomines_blocksbroken_<mine>%` | `volume - blocks` |
|
||||
| `%lomines_total_<mine>%` | `mine.volume` |
|
||||
| `%lomines_resettime_<mine>%` | время до сброса |
|
||||
| `%lomines_stats_total%` | `playerStats.totalBlocks` |
|
||||
| `%lomines_stats_<mine>%` | `playerStats.getMineStat(mine)` |
|
||||
| `%lomines_top_<pos>_name%` | имя в общем топе |
|
||||
| `%lomines_top_<pos>_count%` | блоков в общем топе |
|
||||
| `%lomines_top_<mine>_<pos>_name%` | имя в топе по шахте |
|
||||
| `%lomines_top_<mine>_<pos>_count%` | блоков в топе по шахте |
|
||||
|
||||
### WorldGuard
|
||||
|
||||
`WGRegionManager` создаёт/удаляет WG-регион при операциях с шахтой.
|
||||
Настройки: `Config.WORLDGUARD_ENABLED`, `WORLDGUARD_AUTO_CREATE_REGIONS`, `WORLDGUARD_REGION_PRIORITY`.
|
||||
|
||||
---
|
||||
|
||||
## Система статистики
|
||||
|
||||
```
|
||||
StatsManager
|
||||
├── ConcurrentHashMap<UUID, PlayerStats> 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` | низкий |
|
||||
88
build.gradle.kts
Normal file
88
build.gradle.kts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||
92
gradlew.bat
vendored
Normal file
92
gradlew.bat
vendored
Normal file
|
|
@ -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
|
||||
0
libs/.gitkeep
Normal file
0
libs/.gitkeep
Normal file
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
|||
rootProject.name = "LoMines"
|
||||
217
src/main/java/com/loki/lomines/LoMinesPlugin.java
Normal file
217
src/main/java/com/loki/lomines/LoMinesPlugin.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
37
src/main/java/com/loki/lomines/block/BlockSetter.java
Normal file
37
src/main/java/com/loki/lomines/block/BlockSetter.java
Normal file
|
|
@ -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.
|
||||
*
|
||||
* <p>Implementations:</p>
|
||||
* <ul>
|
||||
* <li>BukkitBlockSetter - For vanilla Minecraft blocks</li>
|
||||
* <li>OraxenBlockSetter - For Oraxen custom blocks</li>
|
||||
* <li>ItemsAdderBlockSetter - For ItemsAdder custom blocks</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This is a placeholder that will be fully implemented in task 2.7.</p>
|
||||
*/
|
||||
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<Location> locations, IntConsumer callback);
|
||||
}
|
||||
105
src/main/java/com/loki/lomines/block/BukkitBlockSetter.java
Normal file
105
src/main/java/com/loki/lomines/block/BukkitBlockSetter.java
Normal file
|
|
@ -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<String, Double> weights;
|
||||
private final LoMinesPlugin plugin;
|
||||
|
||||
public BukkitBlockSetter(Map<String, Double> 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<Location> 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<Location> 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<String, Double> 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);
|
||||
}
|
||||
}
|
||||
100
src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java
Normal file
100
src/main/java/com/loki/lomines/block/ItemsAdderBlockSetter.java
Normal file
|
|
@ -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<String, Double> itemsAdderIds;
|
||||
private final LoMinesPlugin plugin;
|
||||
|
||||
public ItemsAdderBlockSetter(Map<String, Double> 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<Location> 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<Location> 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<String, Double> entry : itemsAdderIds.entrySet()) {
|
||||
currentWeight += entry.getValue();
|
||||
if (random <= currentWeight) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first entry
|
||||
return itemsAdderIds.keySet().iterator().next();
|
||||
}
|
||||
}
|
||||
100
src/main/java/com/loki/lomines/block/OraxenBlockSetter.java
Normal file
100
src/main/java/com/loki/lomines/block/OraxenBlockSetter.java
Normal file
|
|
@ -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<String, Double> oraxenIds;
|
||||
private final LoMinesPlugin plugin;
|
||||
|
||||
public OraxenBlockSetter(Map<String, Double> 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<Location> 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<Location> 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<String, Double> entry : oraxenIds.entrySet()) {
|
||||
currentWeight += entry.getValue();
|
||||
if (random <= currentWeight) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first entry
|
||||
return oraxenIds.keySet().iterator().next();
|
||||
}
|
||||
}
|
||||
12
src/main/java/com/loki/lomines/block/package-info.java
Normal file
12
src/main/java/com/loki/lomines/block/package-info.java
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Block system for mine filling operations.
|
||||
* <p>
|
||||
* This package contains the abstraction for setting blocks in mines:
|
||||
* <ul>
|
||||
* <li>{@code BlockSetter} - Abstract base class for block placement</li>
|
||||
* <li>{@code BukkitBlockSetter} - Implementation for vanilla Bukkit blocks</li>
|
||||
* <li>{@code OraxenBlockSetter} - Implementation for Oraxen custom blocks</li>
|
||||
* <li>{@code ItemsAdderBlockSetter} - Implementation for ItemsAdder custom blocks</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.block;
|
||||
341
src/main/java/com/loki/lomines/command/MineCommands.java
Normal file
341
src/main/java/com/loki/lomines/command/MineCommands.java
Normal file
|
|
@ -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 <name>
|
||||
*/
|
||||
@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 <name>
|
||||
*/
|
||||
@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 <mine> [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<Mine> 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<String, Long> byMine = ps.getMineStatsSnapshot();
|
||||
if (byMine.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(Component.text("По шахтам:", NamedTextColor.GRAY));
|
||||
byMine.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>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<LeaderboardEntry> rows = lb.getTopByMine(key, limit);
|
||||
sender.sendMessage(Component.text("=== Топ по шахте «" + key + "» ===", NamedTextColor.GOLD));
|
||||
sendTopRows(sender, rows);
|
||||
return;
|
||||
}
|
||||
List<LeaderboardEntry> rows = lb.getTopTotal(limit);
|
||||
sender.sendMessage(Component.text("=== Общий топ ===", NamedTextColor.GOLD));
|
||||
sendTopRows(sender, rows);
|
||||
}
|
||||
|
||||
private static void sendTopRows(CommandSender sender, List<LeaderboardEntry> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/main/java/com/loki/lomines/command/package-info.java
Normal file
13
src/main/java/com/loki/lomines/command/package-info.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Command classes for mine management.
|
||||
* <p>
|
||||
* This package contains command implementations using LoAPI's command framework:
|
||||
* <ul>
|
||||
* <li>{@code MineCommands} - Core mine management commands (create, delete, reset, reload, list)</li>
|
||||
* <li>{@code MineUtilityCommands} - Utility commands (wand, teleport)</li>
|
||||
* <li>{@code MineEditorCommands} - GUI editor commands</li>
|
||||
* <li>{@code StatsCommands} - Statistics and leaderboard commands</li>
|
||||
* <li>{@code WorldGuardCommands} - WorldGuard integration commands</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.command;
|
||||
218
src/main/java/com/loki/lomines/core/Mine.java
Normal file
218
src/main/java/com/loki/lomines/core/Mine.java
Normal file
|
|
@ -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<Cuboid> 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<String> 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<Cuboid> parseRegions(MineConfig config) {
|
||||
List<Location> 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<Cuboid> 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<String, Double> 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<Cuboid> 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; }
|
||||
}
|
||||
60
src/main/java/com/loki/lomines/core/MineTicker.java
Normal file
60
src/main/java/com/loki/lomines/core/MineTicker.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/main/java/com/loki/lomines/core/Mines.java
Normal file
247
src/main/java/com/loki/lomines/core/Mines.java
Normal file
|
|
@ -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<String, Mine> 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<Path> 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<String>());
|
||||
yaml.set("random-rewards", new ArrayList<Map<String, Object>>());
|
||||
yaml.set("fill-mode", "cuboid");
|
||||
yaml.set("mask.marker", "pink_concrete");
|
||||
yaml.set("mask.positions", new ArrayList<String>());
|
||||
|
||||
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<Mine> find(String name) {
|
||||
return Optional.ofNullable(mines.get(name.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all mines.
|
||||
*/
|
||||
public Collection<Mine> getAll() {
|
||||
return Collections.unmodifiableCollection(mines.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a mine that contains the given location.
|
||||
*/
|
||||
public Optional<Mine> 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<Location> 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<String> 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);
|
||||
}
|
||||
}
|
||||
11
src/main/java/com/loki/lomines/core/package-info.java
Normal file
11
src/main/java/com/loki/lomines/core/package-info.java
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Core components of the LoMines plugin.
|
||||
* <p>
|
||||
* This package contains the main plugin class and core system classes:
|
||||
* <ul>
|
||||
* <li>{@code LoMinesPlugin} - Main plugin class extending LoPlugin</li>
|
||||
* <li>{@code Mines} - Registry for managing all active mines</li>
|
||||
* <li>{@code Mine} - Representation of a single mine with its configuration and state</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.core;
|
||||
712
src/main/java/com/loki/lomines/data/ConfigLoader.java
Normal file
712
src/main/java/com/loki/lomines/data/ConfigLoader.java
Normal file
|
|
@ -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<Location> parseSelections(YamlConfiguration yaml) throws ConfigParseException {
|
||||
List<Location> 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<String, Double> parseBlocks(YamlConfiguration yaml) throws ConfigParseException {
|
||||
ConfigurationSection contentsSection = yaml.getConfigurationSection("contents");
|
||||
|
||||
if (contentsSection == null) {
|
||||
throw new ConfigParseException(
|
||||
"Missing required 'contents' section in configuration"
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Double> 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<Location> parseMaskPositions(YamlConfiguration yaml) throws ConfigParseException {
|
||||
List<String> rawList = yaml.getStringList("mask.positions");
|
||||
if (rawList == null || rawList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Location> 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<Reward> parseRewards(YamlConfiguration yaml) throws ConfigParseException {
|
||||
List<Reward> 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<String, Object> rewardMap = (Map<String, Object>) 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<String, Object> 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<Material> materials = parseRewardMaterials(rewardMap, index);
|
||||
|
||||
// Parse items
|
||||
List<ItemStack> items = parseRewardItems(rewardMap, index);
|
||||
|
||||
// Parse commands
|
||||
List<String> commands = parseRewardCommands(rewardMap, index);
|
||||
|
||||
return new Reward(materials, chance, items, commands, preventDrops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the chance field from a reward map.
|
||||
*/
|
||||
private double parseRewardChance(Map<String, Object> 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<Material> parseRewardMaterials(Map<String, Object> 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<String> blocksList = (List<String>) blocksObj;
|
||||
|
||||
if (blocksList.isEmpty()) {
|
||||
throw new ConfigParseException("Reward 'blocks' list cannot be empty");
|
||||
}
|
||||
|
||||
List<Material> 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<ItemStack> parseRewardItems(Map<String, Object> rewardMap, int index)
|
||||
throws ConfigParseException {
|
||||
List<ItemStack> 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<String, Object> itemMap = (Map<String, Object>) itemObj;
|
||||
|
||||
ItemStack item = parseRewardItem(itemMap, i);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single item from a map.
|
||||
*/
|
||||
private ItemStack parseRewardItem(Map<String, Object> 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<String> parseRewardCommands(Map<String, Object> rewardMap, int index)
|
||||
throws ConfigParseException {
|
||||
List<String> 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<String> commandsList = (List<String>) 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<Location> 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<String, Double> blocks) {
|
||||
for (Map.Entry<String, Double> 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<Location> maskPositions) {
|
||||
if (maskPositions == null || maskPositions.isEmpty()) {
|
||||
yaml.set("mask.positions", new ArrayList<String>());
|
||||
return;
|
||||
}
|
||||
List<String> 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<Reward> rewards) {
|
||||
if (rewards.isEmpty()) {
|
||||
return; // Don't write empty rewards section
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rewardsList = new ArrayList<>();
|
||||
|
||||
for (Reward reward : rewards) {
|
||||
Map<String, Object> rewardMap = new HashMap<>();
|
||||
|
||||
// Save chance
|
||||
rewardMap.put("chance", reward.getChance());
|
||||
|
||||
// Save prevent-drops
|
||||
rewardMap.put("prevent-drops", reward.isPreventDrops());
|
||||
|
||||
// Save blocks (materials)
|
||||
List<String> blockNames = new ArrayList<>();
|
||||
for (Material material : reward.getMaterials()) {
|
||||
blockNames.add(material.name().toLowerCase());
|
||||
}
|
||||
rewardMap.put("blocks", blockNames);
|
||||
|
||||
// Save items
|
||||
if (!reward.getItems().isEmpty()) {
|
||||
List<Map<String, Object>> itemsList = new ArrayList<>();
|
||||
for (ItemStack item : reward.getItems()) {
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
13
src/main/java/com/loki/lomines/data/FillMode.java
Normal file
13
src/main/java/com/loki/lomines/data/FillMode.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package com.loki.lomines.data;
|
||||
|
||||
/**
|
||||
* How mine contents are placed on reset.
|
||||
* <ul>
|
||||
* <li>{@link #CUBOID} — fill entire selection box (classic)</li>
|
||||
* <li>{@link #MASK} — only at stored block positions (painted with a marker block, then scanned)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum FillMode {
|
||||
CUBOID,
|
||||
MASK
|
||||
}
|
||||
105
src/main/java/com/loki/lomines/data/Leaderboard.java
Normal file
105
src/main/java/com/loki/lomines/data/Leaderboard.java
Normal file
|
|
@ -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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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;
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/loki/lomines/data/LeaderboardEntry.java
Normal file
45
src/main/java/com/loki/lomines/data/LeaderboardEntry.java
Normal file
|
|
@ -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 + "}";
|
||||
}
|
||||
}
|
||||
291
src/main/java/com/loki/lomines/data/MineConfig.java
Normal file
291
src/main/java/com/loki/lomines/data/MineConfig.java
Normal file
|
|
@ -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<Location> selections;
|
||||
|
||||
// Blocks
|
||||
private final Map<String, Double> 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<Location> maskPositions;
|
||||
|
||||
// Rewards
|
||||
private final List<Reward> rewards;
|
||||
|
||||
// Reset
|
||||
private final int resetTicks;
|
||||
private final double resetPercent;
|
||||
private final boolean resetOnPercentEnabled;
|
||||
private final List<String> 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<Location> getSelections() {
|
||||
return selections;
|
||||
}
|
||||
|
||||
public Map<String, Double> 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<Location> getMaskPositions() {
|
||||
return maskPositions;
|
||||
}
|
||||
|
||||
public List<Reward> getRewards() {
|
||||
return rewards;
|
||||
}
|
||||
|
||||
public int getResetTicks() {
|
||||
return resetTicks;
|
||||
}
|
||||
|
||||
public double getResetPercent() {
|
||||
return resetPercent;
|
||||
}
|
||||
|
||||
public boolean isResetOnPercentEnabled() {
|
||||
return resetOnPercentEnabled;
|
||||
}
|
||||
|
||||
public List<String> 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<Location> selections = new ArrayList<>();
|
||||
private Map<String, Double> blocks = new HashMap<>();
|
||||
private FillMode fillMode = FillMode.CUBOID;
|
||||
private String maskMarkerMaterial = "pink_concrete";
|
||||
private List<Location> maskPositions = new ArrayList<>();
|
||||
private List<Reward> rewards = new ArrayList<>();
|
||||
private int resetTicks = 6000;
|
||||
private double resetPercent = 10.0;
|
||||
private boolean resetOnPercentEnabled = false;
|
||||
private List<String> 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<Location> selections) {
|
||||
this.selections = new ArrayList<>(selections);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder blocks(Map<String, Double> 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<Location> maskPositions) {
|
||||
this.maskPositions = maskPositions != null ? new ArrayList<>(maskPositions) : new ArrayList<>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder rewards(List<Reward> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/main/java/com/loki/lomines/data/PlayerStats.java
Normal file
57
src/main/java/com/loki/lomines/data/PlayerStats.java
Normal file
|
|
@ -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<String, AtomicLong> 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<String, Long> getMineStatsSnapshot() {
|
||||
java.util.Map<String, Long> snapshot = new java.util.HashMap<>();
|
||||
mineStats.forEach((mine, counter) -> snapshot.put(mine, counter.get()));
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
70
src/main/java/com/loki/lomines/data/Reward.java
Normal file
70
src/main/java/com/loki/lomines/data/Reward.java
Normal file
|
|
@ -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<Material> materials;
|
||||
private final double chance;
|
||||
private final List<ItemStack> items;
|
||||
private final List<String> commands;
|
||||
private final boolean preventDrops;
|
||||
|
||||
public Reward(List<Material> materials, double chance,
|
||||
List<ItemStack> items, List<String> 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<Material> getMaterials() {
|
||||
return materials;
|
||||
}
|
||||
|
||||
public double getChance() {
|
||||
return chance;
|
||||
}
|
||||
|
||||
public List<ItemStack> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public List<String> 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);
|
||||
}
|
||||
}
|
||||
181
src/main/java/com/loki/lomines/data/StatsManager.java
Normal file
181
src/main/java/com/loki/lomines/data/StatsManager.java
Normal file
|
|
@ -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<UUID, PlayerStats> 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<UUID, PlayerStats> 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<String, Long> mineStats = playerStats.getMineStatsSnapshot();
|
||||
if (!mineStats.isEmpty()) {
|
||||
for (Map.Entry<String, Long> 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<PlayerStats> getAllStats() {
|
||||
return Collections.unmodifiableCollection(stats.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the leaderboard instance.
|
||||
*/
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
}
|
||||
15
src/main/java/com/loki/lomines/data/package-info.java
Normal file
15
src/main/java/com/loki/lomines/data/package-info.java
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Data components for statistics and configuration.
|
||||
* <p>
|
||||
* This package contains classes for data management:
|
||||
* <ul>
|
||||
* <li>{@code MineConfig} - Configuration data model for mines</li>
|
||||
* <li>{@code Reward} - Reward configuration data model</li>
|
||||
* <li>{@code PlayerStats} - Player statistics tracking</li>
|
||||
* <li>{@code StatsManager} - Manager for all player statistics</li>
|
||||
* <li>{@code Leaderboard} - Leaderboard generation and caching</li>
|
||||
* <li>{@code ConfigLoader} - Configuration loading and saving</li>
|
||||
* <li>{@code ConfigParseException} - Exception for configuration parsing errors</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.data;
|
||||
210
src/main/java/com/loki/lomines/gui/GroupCreateGui.java
Normal file
210
src/main/java/com/loki/lomines/gui/GroupCreateGui.java
Normal file
|
|
@ -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<Component> 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));
|
||||
}
|
||||
}
|
||||
32
src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java
Normal file
32
src/main/java/com/loki/lomines/gui/GroupCreateGuiHolder.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
13
src/main/java/com/loki/lomines/gui/package-info.java
Normal file
13
src/main/java/com/loki/lomines/gui/package-info.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* GUI editors for mine configuration.
|
||||
* <p>
|
||||
* This package contains graphical user interface implementations using LoAPI's InventoryGUI:
|
||||
* <ul>
|
||||
* <li>{@code MinesEditor} - List view of all mines</li>
|
||||
* <li>{@code MineEditor} - Main menu for a specific mine</li>
|
||||
* <li>{@code MineContentsEditor} - Editor for mine block contents</li>
|
||||
* <li>{@code MineRewardsEditor} - List view of mine rewards</li>
|
||||
* <li>{@code MineRewardEditor} - Editor for a specific reward</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.gui;
|
||||
91
src/main/java/com/loki/lomines/handler/ActionBarHandler.java
Normal file
91
src/main/java/com/loki/lomines/handler/ActionBarHandler.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/loki/lomines/handler/MineBlockHandler.java
Normal file
58
src/main/java/com/loki/lomines/handler/MineBlockHandler.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/main/java/com/loki/lomines/handler/MineResetHandler.java
Normal file
152
src/main/java/com/loki/lomines/handler/MineResetHandler.java
Normal file
|
|
@ -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.
|
||||
*
|
||||
* <p>Reset process:</p>
|
||||
* <ol>
|
||||
* <li>Check if reset is already running (atomic check)</li>
|
||||
* <li>Fill all regions asynchronously via BlockSetter</li>
|
||||
* <li>Wait for all regions to complete</li>
|
||||
* <li>Execute post-reset actions in main thread</li>
|
||||
* </ol>
|
||||
*/
|
||||
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<Cuboid> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Reward> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/main/java/com/loki/lomines/handler/package-info.java
Normal file
12
src/main/java/com/loki/lomines/handler/package-info.java
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Handler components implementing business logic.
|
||||
* <p>
|
||||
* This package contains classes that handle specific aspects of mine operations:
|
||||
* <ul>
|
||||
* <li>{@code MineResetHandler} - Manages mine reset operations</li>
|
||||
* <li>{@code MineBlockHandler} - Handles block break events in mines</li>
|
||||
* <li>{@code MineRewardHandler} - Manages reward distribution to players</li>
|
||||
* <li>{@code ActionBarHandler} - Displays information in action bar</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.handler;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
11
src/main/java/com/loki/lomines/integration/package-info.java
Normal file
11
src/main/java/com/loki/lomines/integration/package-info.java
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Integration with external plugins.
|
||||
* <p>
|
||||
* This package contains integrations with third-party plugins:
|
||||
* <ul>
|
||||
* <li>{@code IntegrationManager} - Manages all plugin integrations</li>
|
||||
* <li>{@code LoMinesPlaceholders} - PlaceholderAPI expansion</li>
|
||||
* <li>{@code WGRegionManager} - WorldGuard region management</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.integration;
|
||||
|
|
@ -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> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
12
src/main/java/com/loki/lomines/listener/package-info.java
Normal file
12
src/main/java/com/loki/lomines/listener/package-info.java
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Event listeners for Bukkit events.
|
||||
* <p>
|
||||
* This package contains event listener implementations:
|
||||
* <ul>
|
||||
* <li>{@code BlockBreakListener} - Handles block break events in mines</li>
|
||||
* <li>{@code PlayerInteractListener} - Handles selection wand interactions</li>
|
||||
* <li>{@code PlayerJoinListener} - Handles player join for statistics loading</li>
|
||||
* <li>{@code SelectionParticleTask} - Displays particles for active selections</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.listener;
|
||||
21
src/main/java/com/loki/lomines/util/BlockKeys.java
Normal file
21
src/main/java/com/loki/lomines/util/BlockKeys.java
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
185
src/main/java/com/loki/lomines/util/ChunkUtils.java
Normal file
185
src/main/java/com/loki/lomines/util/ChunkUtils.java
Normal file
|
|
@ -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.
|
||||
*
|
||||
* <p>Paper detection is done through method reflection rather than Class.forName
|
||||
* to provide a more reliable mechanism as per requirement 21.4.</p>
|
||||
*/
|
||||
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<ChunkCoordinate> 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<ChunkCoordinate> getAffectedChunks(Cuboid region) {
|
||||
Set<ChunkCoordinate> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
src/main/java/com/loki/lomines/util/Cuboid.java
Normal file
182
src/main/java/com/loki/lomines/util/Cuboid.java
Normal file
|
|
@ -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.
|
||||
*
|
||||
* <p>This class is thread-safe due to its immutability.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
96
src/main/java/com/loki/lomines/util/LocationParser.java
Normal file
96
src/main/java/com/loki/lomines/util/LocationParser.java
Normal file
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/loki/lomines/util/MaskScanner.java
Normal file
45
src/main/java/com/loki/lomines/util/MaskScanner.java
Normal file
|
|
@ -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<Location> scan(List<Cuboid> regions, Material marker) {
|
||||
List<Location> 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;
|
||||
}
|
||||
}
|
||||
138
src/main/java/com/loki/lomines/util/Selection.java
Normal file
138
src/main/java/com/loki/lomines/util/Selection.java
Normal file
|
|
@ -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].
|
||||
*
|
||||
* <p>This class is NOT thread-safe and should be used per-player.</p>
|
||||
*/
|
||||
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<Location> 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<Cuboid> toCuboids() {
|
||||
List<Cuboid> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/loki/lomines/util/SelectionManager.java
Normal file
86
src/main/java/com/loki/lomines/util/SelectionManager.java
Normal file
|
|
@ -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.
|
||||
*
|
||||
* <p>This class is thread-safe and can be accessed from multiple threads.</p>
|
||||
*
|
||||
* <p><b>Validates:</b> Requirements 12.7, 16.7</p>
|
||||
*/
|
||||
public final class SelectionManager {
|
||||
|
||||
private final Map<UUID, Selection> 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<Selection> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/main/java/com/loki/lomines/util/TimeFormatter.java
Normal file
67
src/main/java/com/loki/lomines/util/TimeFormatter.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
14
src/main/java/com/loki/lomines/util/package-info.java
Normal file
14
src/main/java/com/loki/lomines/util/package-info.java
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Utility classes and helpers.
|
||||
* <p>
|
||||
* This package contains utility classes used throughout the plugin:
|
||||
* <ul>
|
||||
* <li>{@code Cuboid} - Represents a rectangular region in the world</li>
|
||||
* <li>{@code Selection} - Manages player region selections</li>
|
||||
* <li>{@code SelectionManager} - Manages selections for all players</li>
|
||||
* <li>{@code LocationParser} - Parses and formats location strings</li>
|
||||
* <li>{@code TimeFormatter} - Formats time values for display</li>
|
||||
* <li>{@code ChunkUtils} - Utilities for chunk operations</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.loki.lomines.util;
|
||||
59
src/main/java/com/loki/lomines/wand/GroupWandItem.java
Normal file
59
src/main/java/com/loki/lomines/wand/GroupWandItem.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
21
src/main/java/com/loki/lomines/wand/GroupWandManager.java
Normal file
21
src/main/java/com/loki/lomines/wand/GroupWandManager.java
Normal file
|
|
@ -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<UUID, GroupWandSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
public GroupWandSession getSession(UUID playerId) {
|
||||
return sessions.computeIfAbsent(playerId, u -> new GroupWandSession());
|
||||
}
|
||||
|
||||
public void removeSession(UUID playerId) {
|
||||
sessions.remove(playerId);
|
||||
}
|
||||
}
|
||||
82
src/main/java/com/loki/lomines/wand/GroupWandSession.java
Normal file
82
src/main/java/com/loki/lomines/wand/GroupWandSession.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
13
src/main/resources/config.yml
Normal file
13
src/main/resources/config.yml
Normal file
|
|
@ -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
|
||||
44
src/main/resources/plugin.yml
Normal file
44
src/main/resources/plugin.yml
Normal file
|
|
@ -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: /<command> <subcommand>
|
||||
|
||||
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
|
||||
150
src/test/java/com/loki/lomines/command/MineCommandsTest.java
Normal file
150
src/test/java/com/loki/lomines/command/MineCommandsTest.java
Normal file
|
|
@ -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<Mine> 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));
|
||||
}
|
||||
}
|
||||
120
src/test/java/com/loki/lomines/core/MineTest.java
Normal file
120
src/test/java/com/loki/lomines/core/MineTest.java
Normal file
|
|
@ -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<String, Double> 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<String, Double> 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<String, Double> 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<String, Double> 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<String, Double> 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();
|
||||
}
|
||||
}
|
||||
56
src/test/java/com/loki/lomines/core/MineTickerTest.java
Normal file
56
src/test/java/com/loki/lomines/core/MineTickerTest.java
Normal file
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
401
src/test/java/com/loki/lomines/core/MinesTest.java
Normal file
401
src/test/java/com/loki/lomines/core/MinesTest.java
Normal file
|
|
@ -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> bukkit, MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Scheduler> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
537
src/test/java/com/loki/lomines/data/ConfigLoaderTest.java
Normal file
537
src/test/java/com/loki/lomines/data/ConfigLoaderTest.java
Normal file
|
|
@ -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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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> 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> top1 = leaderboard.getTopTotal(10);
|
||||
long time1 = System.nanoTime() - start1;
|
||||
|
||||
// Second call - uses cache (should be much faster)
|
||||
long start2 = System.nanoTime();
|
||||
List<LeaderboardEntry> 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<LeaderboardEntry> top10 = leaderboard.getTopTotal(10);
|
||||
assertEquals(3, top10.size());
|
||||
|
||||
// Get with limit 2 - should use same cache
|
||||
List<LeaderboardEntry> 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");
|
||||
}
|
||||
}
|
||||
204
src/test/java/com/loki/lomines/data/LeaderboardTest.java
Normal file
204
src/test/java/com/loki/lomines/data/LeaderboardTest.java
Normal file
|
|
@ -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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> top1 = leaderboard.getTopTotal(10);
|
||||
|
||||
// Second call - should use cache (same reference)
|
||||
List<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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<LeaderboardEntry> 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");
|
||||
}
|
||||
}
|
||||
261
src/test/java/com/loki/lomines/data/StatsManagerTest.java
Normal file
261
src/test/java/com/loki/lomines/data/StatsManagerTest.java
Normal file
|
|
@ -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<Scheduler> 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<Scheduler> 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());
|
||||
}
|
||||
}
|
||||
155
src/test/java/com/loki/lomines/util/ChunkUtilsTest.java
Normal file
155
src/test/java/com/loki/lomines/util/ChunkUtilsTest.java
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
179
src/test/java/com/loki/lomines/util/LocationParserTest.java
Normal file
179
src/test/java/com/loki/lomines/util/LocationParserTest.java
Normal file
|
|
@ -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> 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> 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> 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> 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> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/test/java/com/loki/lomines/util/SelectionManagerTest.java
Normal file
128
src/test/java/com/loki/lomines/util/SelectionManagerTest.java
Normal file
|
|
@ -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<Selection> result = manager.findSelection(playerId);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSelection_returnsSelectionWhenExists() {
|
||||
Selection created = manager.getSelection(playerId);
|
||||
Optional<Selection> 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));
|
||||
}
|
||||
}
|
||||
222
src/test/java/com/loki/lomines/util/SelectionTest.java
Normal file
222
src/test/java/com/loki/lomines/util/SelectionTest.java
Normal file
|
|
@ -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<Location> result = selection.getPoint(0);
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals(loc, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPointReturnsEmptyWhenNotSet() {
|
||||
Optional<Location> 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<Cuboid> 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<Cuboid> 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<Cuboid> 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<Cuboid> 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));
|
||||
}
|
||||
}
|
||||
175
src/test/java/com/loki/lomines/util/TimeFormatterTest.java
Normal file
175
src/test/java/com/loki/lomines/util/TimeFormatterTest.java
Normal file
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue