refactor: migrate package from com.loki to dev.loki

- Change base package from com.loki to dev.loki across all Java files
- Update plugin.yml main class path
- Fix ConfigParseException imports in test files
- Update build.gradle.kts relocate patterns
- Add missing imports in ConfigLoaderTest.java
- Update TODO.md with current status

BREAKING CHANGE: Package namespace changed from com.loki to dev.loki
This commit is contained in:
loki5512344 2026-05-31 21:34:44 +02:00
parent 41924ea97f
commit f4e9458544
87 changed files with 2185 additions and 1767 deletions

View file

@ -0,0 +1,83 @@
package dev.loki.lomines;
import dev.loki.lomines.core.MineTicker;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.data.stats.StatsManager;
import dev.loki.lomines.integration.IntegrationManager;
import dev.loki.lomines.wand.GroupWandManager;
import dev.lolib.commands.CommandManager;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Initializes and manages plugin components.
*/
final class ComponentInitializer {
private final LoMinesPlugin plugin;
ComponentInitializer(LoMinesPlugin plugin) {
this.plugin = plugin;
}
void createDirectories() throws IOException {
Path dataFolder = plugin.getDataFolder().toPath();
Path minesFolder = dataFolder.resolve("mines");
if (!Files.exists(minesFolder)) {
Files.createDirectories(minesFolder);
plugin.loLogger().info("Created mines directory");
}
}
Components initialize() {
Mines mines = new Mines(plugin);
GroupWandManager groupWandManager = new GroupWandManager();
StatsManager statsManager = new StatsManager(plugin);
CommandManager commandManager = new CommandManager(plugin);
IntegrationManager integrationManager = new IntegrationManager(plugin);
plugin.loLogger().info("Core components initialized");
return new Components(mines, groupWandManager, statsManager, commandManager, integrationManager);
}
void loadMines(Mines mines) {
try {
mines.loadAll();
plugin.loLogger().info("Loaded " + mines.getAll().size() + " mine(s)");
} catch (IOException e) {
plugin.loLogger().error("Failed to load mines: " + e.getMessage());
e.printStackTrace();
}
}
MineTicker startTicker(Mines mines) {
MineTicker ticker = new MineTicker(mines, plugin);
ticker.start();
return ticker;
}
void startStatistics(StatsManager statsManager) {
if (plugin.getConfig().getBoolean("statistics-enabled", true)) {
try {
statsManager.load();
statsManager.startAutoSave();
plugin.loLogger().info("Statistics system enabled");
} catch (IOException e) {
plugin.loLogger().warn("Failed to load statistics: " + e.getMessage());
}
}
}
record Components(
Mines mines,
GroupWandManager groupWandManager,
StatsManager statsManager,
CommandManager commandManager,
IntegrationManager integrationManager
) {
}
}

View file

@ -0,0 +1,113 @@
package dev.loki.lomines;
import dev.loki.lomines.core.MineTicker;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.data.stats.StatsManager;
import dev.loki.lomines.integration.IntegrationManager;
import dev.loki.lomines.wand.GroupWandManager;
import dev.lolib.commands.CommandManager;
import dev.lolib.core.LoPlugin;
import dev.lolib.core.dependency.DependencyManager;
/**
* 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 {
saveDefaultConfig();
ComponentInitializer initializer = new ComponentInitializer(this);
initializer.createDirectories();
ComponentInitializer.Components components = initializer.initialize();
this.mines = components.mines();
this.groupWandManager = components.groupWandManager();
this.statsManager = components.statsManager();
this.commandManager = components.commandManager();
this.integrationManager = components.integrationManager();
initializer.loadMines(mines);
this.mineTicker = initializer.startTicker(mines);
RegistrationManager registrationManager = new RegistrationManager(this);
registrationManager.registerCommands(commandManager);
registrationManager.registerListeners();
registrationManager.initializeIntegrations(integrationManager);
initializer.startStatistics(statsManager);
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;
}
if (mines != null) {
mines.getAll().forEach(mine -> mine.stop());
}
if (statsManager != null) {
statsManager.stopAutoSave();
statsManager.save();
loLogger().info("Statistics saved successfully");
}
if (integrationManager != null) {
integrationManager.shutdown();
}
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) {
dependencyManager.add("org.apache.commons", "commons-math3", "3.6.1");
dependencyManager.add("commons-io", "commons-io", "2.18.0");
}
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;
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.lomines;
import dev.loki.lomines.command.AdminCommands;
import dev.loki.lomines.command.MaskCommands;
import dev.loki.lomines.command.PlayerCommands;
import dev.loki.lomines.command.StatsCommands;
import dev.loki.lomines.integration.IntegrationManager;
import dev.loki.lomines.listener.BlockBreakListener;
import dev.loki.lomines.listener.GroupGuiListener;
import dev.loki.lomines.listener.PlayerInteractListener;
import dev.loki.lomines.listener.PlayerJoinListener;
import dev.lolib.commands.CommandManager;
/**
* Handles registration of commands, listeners, and integrations.
*/
final class RegistrationManager {
private final LoMinesPlugin plugin;
RegistrationManager(LoMinesPlugin plugin) {
this.plugin = plugin;
}
void registerCommands(CommandManager commandManager) {
commandManager.register(new AdminCommands(plugin));
commandManager.register(new PlayerCommands(plugin));
commandManager.register(new StatsCommands(plugin));
commandManager.register(new MaskCommands(plugin));
plugin.loLogger().info("Commands registered");
}
void registerListeners() {
plugin.getServer().getPluginManager().registerEvents(new BlockBreakListener(plugin), plugin);
plugin.getServer().getPluginManager().registerEvents(new PlayerInteractListener(plugin), plugin);
plugin.getServer().getPluginManager().registerEvents(new GroupGuiListener(plugin), plugin);
if (plugin.getConfig().getBoolean("statistics-enabled", true)) {
plugin.getServer().getPluginManager().registerEvents(new PlayerJoinListener(plugin), plugin);
}
plugin.loLogger().info("Event listeners registered");
}
void initializeIntegrations(IntegrationManager integrationManager) {
integrationManager.initAll();
plugin.loLogger().info("Integrations initialized");
}
}

View file

@ -0,0 +1,37 @@
package dev.loki.lomines.block;
import dev.loki.lomines.util.location.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);
}

View file

@ -0,0 +1,104 @@
package dev.loki.lomines.block;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.util.ValidationUtils;
import dev.loki.lomines.util.location.Cuboid;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.data.BlockData;
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 = ValidationUtils.parseMaterialOrDefault(entry.getKey(), Material.STONE);
return Bukkit.createBlockData(material);
}
}
// Fallback to first entry
String firstKey = weights.keySet().iterator().next();
Material material = ValidationUtils.parseMaterialOrDefault(firstKey, Material.STONE);
return Bukkit.createBlockData(material);
}
}

View 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();
}
}

View 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();
}
}

View 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 dev.loki.lomines.block;

View file

@ -0,0 +1,138 @@
package dev.loki.lomines.command;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.util.ErrorHandler;
import dev.loki.lomines.util.MessageFormatter;
import dev.lolib.commands.annotation.Arg;
import dev.lolib.commands.annotation.Command;
import dev.lolib.commands.annotation.Subcommand;
import net.kyori.adventure.text.Component;
import org.bukkit.command.CommandSender;
import java.io.IOException;
import java.util.Collection;
/**
* Admin commands for mine management.
* Provides commands for creating, deleting, resetting, reloading, and listing mines.
*/
@Command(value = "lm", permission = "lomines.use")
public class AdminCommands {
private final LoMinesPlugin plugin;
private final Mines mines;
private final ErrorHandler errorHandler;
public AdminCommands(LoMinesPlugin plugin) {
this.plugin = plugin;
this.mines = plugin.getMines();
this.errorHandler = new ErrorHandler(plugin.getLogger(), "[LoMines] ");
}
/**
* 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(MessageFormatter.success("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) {
errorHandler.handleNotFound(sender, "Mine already exists", name);
} catch (IOException e) {
errorHandler.handleError(sender, "Failed to create mine: " + e.getMessage(),
"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(MessageFormatter.success("Successfully deleted mine: " + name));
} catch (IllegalArgumentException e) {
errorHandler.handleNotFound(sender, "Mine", name);
} catch (IOException e) {
errorHandler.handleError(sender, "Failed to delete mine: " + e.getMessage(),
"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(MessageFormatter.success("Resetting mine: " + mineName));
} catch (IllegalArgumentException e) {
errorHandler.handleNotFound(sender, "Mine", mineName);
}
}
/**
* Reloads all mine configurations from disk.
* Usage: /lm reload
*/
@Subcommand(value = "reload", permission = "lomines.admin.reload")
public void reload(CommandSender sender) {
try {
mines.getAll().forEach(Mine::stop);
mines.loadAll();
int count = mines.getAll().size();
sender.sendMessage(MessageFormatter.success("Successfully reloaded " + count + " mine(s)"));
} catch (IOException e) {
errorHandler.handleError(sender, "Failed to reload mines: " + e.getMessage(),
"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);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.lomines.command;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.util.ErrorHandler;
import dev.loki.lomines.util.MessageFormatter;
import dev.lolib.commands.annotation.Arg;
import dev.lolib.commands.annotation.Command;
import dev.lolib.commands.annotation.Subcommand;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.command.CommandSender;
import java.io.IOException;
/**
* Mask commands for mine shape management.
* Provides commands for scanning and saving mask positions.
*/
@Command(value = "lm", permission = "lomines.use")
public class MaskCommands {
private final LoMinesPlugin plugin;
private final Mines mines;
private final ErrorHandler errorHandler;
public MaskCommands(LoMinesPlugin plugin) {
this.plugin = plugin;
this.mines = plugin.getMines();
this.errorHandler = new ErrorHandler(plugin.getLogger(), "[LoMines] ");
}
@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(MessageFormatter.success(
"Mask mode: saved " + n + " cell(s) for mine '" + name
+ "'. Reset will spawn ore only at those positions."));
} catch (IllegalArgumentException e) {
errorHandler.handleNotFound(sender, "Mine", name);
} catch (IOException | ConfigParseException e) {
errorHandler.handleError(sender, "Mask scan failed: " + e.getMessage(),
"maskscan failed for " + name, e);
}
});
}
}

View file

@ -0,0 +1,51 @@
package dev.loki.lomines.command;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.util.ErrorHandler;
import dev.loki.lomines.wand.GroupWandItem;
import dev.lolib.commands.annotation.Arg;
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.command.CommandSender;
import org.bukkit.entity.Player;
/**
* Player commands for mine interaction.
* Provides commands for wand and group management.
*/
@Command(value = "lm", permission = "lomines.use")
public class PlayerCommands {
private final LoMinesPlugin plugin;
private final ErrorHandler errorHandler;
public PlayerCommands(LoMinesPlugin plugin) {
this.plugin = plugin;
this.errorHandler = new ErrorHandler(plugin.getLogger(), "[LoMines] ");
}
@Subcommand(value = "wand", permission = "lomines.admin.wand")
public void wand(CommandSender sender) {
if (!(sender instanceof Player player)) {
errorHandler.sendError(sender, "Только для игрока.");
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)) {
errorHandler.sendError(sender, "Префикс задаётся в игре.");
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));
}
}

View file

@ -0,0 +1,186 @@
package dev.loki.lomines.command;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.data.stats.Leaderboard;
import dev.loki.lomines.data.stats.LeaderboardEntry;
import dev.loki.lomines.data.stats.PlayerStats;
import dev.loki.lomines.data.stats.StatsManager;
import dev.loki.lomines.util.ErrorHandler;
import dev.lolib.commands.annotation.Arg;
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.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Statistics commands for player mining stats and leaderboards.
* Provides commands for viewing stats and top players.
*/
@Command(value = "lm", permission = "lomines.use")
public class StatsCommands {
private final LoMinesPlugin plugin;
private final Mines mines;
private final ErrorHandler errorHandler;
public StatsCommands(LoMinesPlugin plugin) {
this.plugin = plugin;
this.mines = plugin.getMines();
this.errorHandler = new ErrorHandler(plugin.getLogger(), "[LoMines] ");
}
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.playerId());
sender.sendMessage(Component.text(
(i + 1) + ". " + name + " — " + e.count(),
NamedTextColor.WHITE));
}
}
private static String formatPlayerName(UUID id) {
String name = Bukkit.getOfflinePlayer(id).getName();
return name != null ? name : id.toString();
}
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;
}
}
/**
* 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)) {
errorHandler.sendError(sender, "Укажите игрока: /lm stats <ник>");
return;
}
uuid = self.getUniqueId();
} else {
if (!sender.hasPermission("lomines.stats.others")) {
errorHandler.handlePermissionDenied(sender, "смотреть чужую статистику");
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()) {
errorHandler.handleNotFound(sender, "Шахта", mineName);
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 boolean isStatsDisabled(CommandSender sender) {
if (!plugin.getConfig().getBoolean("statistics-enabled", true)) {
sender.sendMessage(Component.text("Статистика отключена в config.yml.", NamedTextColor.RED));
return true;
}
return false;
}
}

View 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 dev.loki.lomines.command;

View file

@ -0,0 +1,240 @@
package dev.loki.lomines.core;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.block.BlockSetter;
import dev.loki.lomines.data.config.FillMode;
import dev.loki.lomines.data.config.MineConfig;
import dev.loki.lomines.handler.ActionBarHandler;
import dev.loki.lomines.handler.MineBlockHandler;
import dev.loki.lomines.handler.MineResetHandler;
import dev.loki.lomines.util.location.BlockKeys;
import dev.loki.lomines.util.location.Cuboid;
import dev.lolib.scheduler.ScheduledTask;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import java.util.*;
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 dev.loki.lomines.block.OraxenBlockSetter(blocks, plugin);
throw new IllegalArgumentException("Oraxen integration is currently disabled");
} else if (firstKey.startsWith("itemsadder:")) {
// return new dev.loki.lomines.block.ItemsAdderBlockSetter(blocks, plugin);
throw new IllegalArgumentException("ItemsAdder integration is currently disabled");
} else {
return new dev.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;
}
}

View file

@ -0,0 +1,60 @@
package dev.loki.lomines.core;
import dev.loki.lomines.LoMinesPlugin;
import dev.lolib.scheduler.ScheduledTask;
import dev.lolib.scheduler.Scheduler;
/**
* 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);
}
}
}

View file

@ -0,0 +1,161 @@
package dev.loki.lomines.core;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.service.MaskScanService;
import dev.loki.lomines.core.service.MineFileManager;
import dev.loki.lomines.core.service.MineRepository;
import dev.loki.lomines.data.config.MineConfig;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import org.bukkit.Location;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Optional;
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 LoMinesPlugin plugin;
private final MineFileManager fileManager;
private final MineRepository repository;
private final MaskScanService maskScanService;
public Mines(LoMinesPlugin plugin) {
this.plugin = plugin;
Path minesFolder = plugin.getDataFolder().toPath().resolve("mines");
this.fileManager = new MineFileManager(minesFolder);
this.repository = new MineRepository(plugin, fileManager);
this.maskScanService = new MaskScanService(repository, fileManager);
}
/**
* Loads all mines from the mines/ folder.
* Clears and replaces any previously loaded mines (used for full reload).
*/
public void loadAll() throws IOException {
repository.clear();
Path minesFolder = fileManager.getMinesFolder();
if (!Files.exists(minesFolder)) {
return;
}
try (Stream<Path> paths = Files.list(minesFolder)) {
paths.filter(path -> path.toString().endsWith(".yml"))
.forEach(configFile -> loadMineFromFile(configFile));
}
}
private void loadMineFromFile(Path configFile) {
try {
String fileName = configFile.getFileName().toString();
String mineName = fileName.substring(0, fileName.length() - 4);
MineConfig config = fileManager.loadConfig(mineName);
repository.createAndStart(mineName, config);
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 {
if (repository.exists(name)) {
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");
}
fileManager.ensureFolderExists();
fileManager.createDefaultConfig(name, corner1, corner2);
MineConfig config;
try {
config = fileManager.loadConfig(name);
} catch (ConfigParseException e) {
throw new IOException("Failed to parse created mine configuration: " + e.getMessage(), e);
}
repository.createAndStart(name, config);
plugin.loLogger().info("Created mine: " + name);
}
/**
* Deletes a mine by name.
*/
public void delete(String name) throws IOException {
Mine mine = repository.remove(name);
if (mine == null) {
throw new IllegalArgumentException("Mine not found: " + name);
}
mine.stop();
fileManager.deleteConfig(name);
}
/**
* Gets a mine by name.
*
* @throws IllegalArgumentException if mine not found
*/
public Mine get(String name) {
return repository.get(name);
}
/**
* Finds a mine by name, returning Optional.
*/
public Optional<Mine> find(String name) {
return repository.find(name);
}
/**
* Returns all mines.
*/
public Collection<Mine> getAll() {
return repository.getAll();
}
/**
* Finds a mine that contains the given location.
*/
public Optional<Mine> findByLocation(Location location) {
return repository.findByLocation(location);
}
/**
* 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 {
int count = maskScanService.scanAndSave(mineName);
plugin.loLogger().info("Mask scan for '" + mineName + "': " + count + " marker block(s)");
return count;
}
/**
* Reloads one mine from disk (stop, replace instance, start tasks).
*/
public void reloadMine(String name) throws IOException, ConfigParseException {
repository.reload(name);
}
}

View 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 dev.loki.lomines.core;

View file

@ -0,0 +1,48 @@
package dev.loki.lomines.core.service;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.util.ValidationUtils;
import dev.loki.lomines.util.location.Cuboid;
import dev.loki.lomines.util.selection.MaskScanner;
import org.bukkit.Location;
import org.bukkit.Material;
import java.io.IOException;
import java.util.List;
/**
* Service for scanning and saving mask positions.
*/
public final class MaskScanService {
private final MineRepository repository;
private final MineFileManager fileManager;
public MaskScanService(MineRepository repository, MineFileManager fileManager) {
this.repository = repository;
this.fileManager = fileManager;
}
public int scanAndSave(String mineName) throws IOException, ConfigParseException {
Mine mine = repository.get(mineName);
Material marker = parseMarkerMaterial(mine.getConfig().getMaskMarkerMaterial());
List<Cuboid> regions = mine.getRegions();
List<Location> found = MaskScanner.scan(regions, marker);
fileManager.saveMaskPositions(
mineName,
mine.getConfig().getMaskMarkerMaterial(),
found
);
repository.reload(mineName);
return found.size();
}
private Material parseMarkerMaterial(String materialName) {
return ValidationUtils.parseMaterialOrDefault(materialName, Material.PINK_CONCRETE);
}
}

View file

@ -0,0 +1,94 @@
package dev.loki.lomines.core.service;
import dev.loki.lomines.data.config.ConfigLoader;
import dev.loki.lomines.data.config.MineConfig;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.util.location.LocationParser;
import org.bukkit.Location;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Handles file operations for mine configurations.
*/
public record MineFileManager(Path minesFolder) {
public Path getMinesFolder() {
return minesFolder;
}
public void ensureFolderExists() throws IOException {
if (!Files.exists(minesFolder)) {
Files.createDirectories(minesFolder);
}
}
public void createDefaultConfig(String name, Location corner1, Location corner2) throws IOException {
Path configFile = minesFolder.resolve(name + ".yml");
YamlConfiguration yaml = new YamlConfiguration();
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());
}
public MineConfig loadConfig(String name) throws IOException, ConfigParseException {
Path configFile = minesFolder.resolve(name + ".yml");
if (!Files.exists(configFile)) {
throw new IOException("Mine config not found: " + configFile);
}
ConfigLoader loader = new ConfigLoader();
return loader.load(configFile);
}
public void deleteConfig(String name) throws IOException {
Path configFile = minesFolder.resolve(name + ".yml");
if (Files.exists(configFile)) {
Files.delete(configFile);
}
}
public void saveMaskPositions(String name, String markerMaterial, List<Location> positions) throws IOException {
Path configFile = minesFolder.resolve(name + ".yml");
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configFile.toFile());
yaml.set("fill-mode", "mask");
yaml.set("mask.marker", markerMaterial);
List<String> lines = new ArrayList<>();
for (Location loc : positions) {
lines.add(LocationParser.format(loc));
}
yaml.set("mask.positions", lines);
yaml.save(configFile.toFile());
}
}

View file

@ -0,0 +1,88 @@
package dev.loki.lomines.core.service;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.data.config.MineConfig;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import org.bukkit.Location;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Repository for managing mine instances.
*/
public final class MineRepository {
private final Map<String, Mine> mines = new ConcurrentHashMap<>();
private final LoMinesPlugin plugin;
private final MineFileManager fileManager;
public MineRepository(LoMinesPlugin plugin, MineFileManager fileManager) {
this.plugin = plugin;
this.fileManager = fileManager;
}
public void add(String name, Mine mine) {
mines.put(name.toLowerCase(), mine);
}
public Mine remove(String name) {
return mines.remove(name.toLowerCase());
}
public Mine get(String name) {
Mine mine = mines.get(name.toLowerCase());
if (mine == null) {
throw new IllegalArgumentException("Mine not found: " + name);
}
return mine;
}
public Optional<Mine> find(String name) {
return Optional.ofNullable(mines.get(name.toLowerCase()));
}
public Collection<Mine> getAll() {
return Collections.unmodifiableCollection(mines.values());
}
public Optional<Mine> findByLocation(Location location) {
return mines.values().stream()
.filter(mine -> mine.contains(location))
.findFirst();
}
public boolean exists(String name) {
return mines.containsKey(name.toLowerCase());
}
public void clear() {
for (Mine mine : new ArrayList<>(mines.values())) {
mine.stop();
}
mines.clear();
}
public Mine createAndStart(String name, MineConfig config) {
Mine mine = new Mine(name, config, plugin);
add(name, mine);
mine.start();
return mine;
}
public void stopAndRemove(String name) {
Mine mine = remove(name);
if (mine != null) {
mine.stop();
}
}
public void reload(String name) throws IOException, ConfigParseException {
stopAndRemove(name);
MineConfig config = fileManager.loadConfig(name);
Mine mine = createAndStart(name, config);
plugin.loLogger().info("Reloaded mine: " + name);
}
}

View file

@ -0,0 +1,111 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.data.config.parser.ConfigParser;
import dev.loki.lomines.data.reward.Reward;
import dev.loki.lomines.data.reward.RewardParser;
import org.bukkit.Location;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* Facade for loading and saving MineConfig objects from/to YAML files.
* Coordinates the work between ConfigParser, ConfigValidator, and ConfigSerializer.
* Follows the Single Responsibility Principle by delegating specific tasks.
*/
public final class ConfigLoader {
private final ConfigParser parser;
private final ConfigValidator validator;
private final ConfigSerializer serializer;
public ConfigLoader() {
RewardParser rewardParser = new RewardParser();
this.parser = new ConfigParser(rewardParser);
this.validator = new ConfigValidator();
this.serializer = new ConfigSerializer();
}
/**
* Loads a MineConfig from a YAML file.
* Parses the file, validates all data, and builds the MineConfig object.
*
* @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());
// Parse all sections
List<Location> selections = parser.parseSelections(yaml);
Map<String, Double> blocks = parser.parseBlocks(yaml);
FillMode fillMode = parser.parseFillMode(yaml);
String maskMarker = parser.parseMaskMarker(yaml);
List<Location> maskPositions = parser.parseMaskPositions(yaml);
List<Reward> rewards = parser.parseRewards(yaml);
Location teleportLocation = parser.parseTeleportLocation(yaml);
// Validate all data
validator.validateSelections(selections);
validator.validateBlocks(blocks);
validator.validateMaskMarker(maskMarker);
validator.validateRewards(rewards);
// Build and return config
return MineConfig.builder()
.selections(selections)
.blocks(blocks)
.fillMode(fillMode)
.maskMarkerMaterial(maskMarker)
.maskPositions(maskPositions)
.rewards(rewards)
.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(teleportLocation)
.resetCommands(yaml.getStringList("reset-commands"))
.broadcastReset(yaml.getString("broadcast-reset", ""))
.build();
}
/**
* Saves a MineConfig to a YAML file.
* Serializes the config and writes it to the specified 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();
serializer.serialize(config, yaml);
yaml.save(configFile.toFile());
}
}

View file

@ -0,0 +1,142 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.reward.Reward;
import dev.loki.lomines.util.location.LocationParser;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.inventory.ItemStack;
import java.util.*;
/**
* Serializes MineConfig objects to YAML format.
* Responsible only for writing MineConfig data to YAML configuration.
*/
final class ConfigSerializer {
/**
* Serializes a MineConfig to YAML configuration.
*
* @param config the MineConfig to serialize
* @param yaml the YAML configuration to write to
*/
void serialize(MineConfig config, YamlConfiguration yaml) {
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());
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());
yaml.set("teleport-on-reset", config.isTeleportOnReset());
if (config.getTeleportLocation() != null) {
yaml.set("teleport-location", LocationParser.format(config.getTeleportLocation()));
}
yaml.set("actionbar.enabled", config.isActionBarEnabled());
yaml.set("actionbar.message", config.getActionBarMessage());
yaml.set("actionbar.range", config.getActionBarRange());
yaml.set("timer-format", config.getTimerFormat());
}
/**
* Saves selection coordinates to the YAML configuration.
*
* @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.
*
* @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();
yaml.set("contents." + blockKey, weight);
}
}
/**
* Saves mask positions to the YAML configuration.
*
* @param yaml the YAML configuration
* @param maskPositions the list of mask positions
*/
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.
*
* @param yaml the YAML configuration
* @param rewards the list of rewards
*/
private void saveRewards(YamlConfiguration yaml, List<Reward> rewards) {
if (rewards.isEmpty()) {
return;
}
List<Map<String, Object>> rewardsList = new ArrayList<>();
for (Reward reward : rewards) {
Map<String, Object> rewardMap = new HashMap<>();
rewardMap.put("chance", reward.getChance());
rewardMap.put("prevent-drops", reward.isPreventDrops());
List<String> blockNames = new ArrayList<>();
for (Material material : reward.getMaterials()) {
blockNames.add(material.name().toLowerCase());
}
rewardMap.put("blocks", blockNames);
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);
}
if (!reward.getCommands().isEmpty()) {
rewardMap.put("commands", new ArrayList<>(reward.getCommands()));
}
rewardsList.add(rewardMap);
}
yaml.set("random-rewards", rewardsList);
}
}

View file

@ -0,0 +1,138 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.data.reward.Reward;
import dev.loki.lomines.util.ValidationUtils;
import org.bukkit.Location;
import java.util.List;
import java.util.Map;
/**
* Validates configuration data according to business rules.
* Responsible only for validation - throws ConfigParseException on invalid data.
*/
final class ConfigValidator {
/**
* Validates selection coordinates.
*
* @param selections the list of selections to validate
* @throws ConfigParseException if selections are invalid
*/
void validateSelections(List<Location> selections) throws ConfigParseException {
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)"
);
}
}
/**
* Validates block contents configuration.
*
* @param blocks the map of block keys to weights
* @throws ConfigParseException if blocks are invalid
*/
void validateBlocks(Map<String, Double> blocks) throws ConfigParseException {
if (blocks.isEmpty()) {
throw new ConfigParseException(
"No blocks defined in 'contents' section. At least one block is required"
);
}
for (Map.Entry<String, Double> entry : blocks.entrySet()) {
String key = entry.getKey();
Double weight = entry.getValue();
if (weight <= 0) {
throw new ConfigParseException(
"Block weight must be positive for '" + key + "', got: " + weight
);
}
// Validate vanilla materials (custom blocks are validated at runtime)
if (!key.startsWith("oraxen:") && !key.startsWith("itemsadder:")) {
validateMaterial(key);
}
}
}
/**
* Validates a material name.
*
* @param materialName the material name to validate
* @throws ConfigParseException if the material is invalid
*/
void validateMaterial(String materialName) throws ConfigParseException {
try {
ValidationUtils.validateMaterial(materialName);
} catch (IllegalArgumentException e) {
throw new ConfigParseException(
"Unknown material: '" + materialName + "'. Must be a valid Minecraft material name",
e
);
}
}
/**
* Validates mask marker material.
*
* @param marker the mask marker material name
* @throws ConfigParseException if the marker is invalid
*/
void validateMaskMarker(String marker) throws ConfigParseException {
try {
ValidationUtils.validateMaterial(marker);
} catch (IllegalArgumentException e) {
throw new ConfigParseException("Invalid mask.marker material: '" + marker + "'", e);
}
}
/**
* Validates a reward configuration.
*
* @param reward the reward to validate
* @throws ConfigParseException if the reward is invalid
*/
void validateReward(Reward reward) throws ConfigParseException {
double chance = reward.getChance();
if (chance < 0 || chance > 100) {
throw new ConfigParseException(
"Reward chance must be between 0 and 100, got: " + chance
);
}
if (reward.getMaterials().isEmpty()) {
throw new ConfigParseException("Reward 'blocks' list cannot be empty");
}
// Validate item amounts
for (org.bukkit.inventory.ItemStack item : reward.getItems()) {
if (item.getAmount() <= 0) {
throw new ConfigParseException(
"Item amount must be positive, got: " + item.getAmount()
);
}
}
}
/**
* Validates all rewards in a list.
*
* @param rewards the list of rewards to validate
* @throws ConfigParseException if any reward is invalid
*/
void validateRewards(List<Reward> rewards) throws ConfigParseException {
for (Reward reward : rewards) {
validateReward(reward);
}
}
}

View file

@ -0,0 +1,13 @@
package dev.loki.lomines.data.config;
/**
* 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
}

View file

@ -0,0 +1,189 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.reward.Reward;
import org.bukkit.Location;
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;
MineConfig(List<Location> selections, Map<String, Double> blocks, FillMode fillMode,
String maskMarkerMaterial, List<Location> maskPositions, List<Reward> rewards,
int resetTicks, double resetPercent, boolean resetOnPercentEnabled,
List<String> resetCommands, String broadcastReset, boolean teleportOnReset,
Location teleportLocation, boolean actionBarEnabled, String actionBarMessage,
double actionBarRange, String timerFormat) {
this.selections = List.copyOf(selections);
this.blocks = Map.copyOf(blocks);
this.fillMode = fillMode;
this.maskMarkerMaterial = maskMarkerMaterial;
this.maskPositions = List.copyOf(maskPositions);
this.rewards = List.copyOf(rewards);
this.resetTicks = resetTicks;
this.resetPercent = resetPercent;
this.resetOnPercentEnabled = resetOnPercentEnabled;
this.resetCommands = List.copyOf(resetCommands);
this.broadcastReset = broadcastReset;
this.teleportOnReset = teleportOnReset;
this.teleportLocation = teleportLocation;
this.actionBarEnabled = actionBarEnabled;
this.actionBarMessage = actionBarMessage;
this.actionBarRange = actionBarRange;
this.timerFormat = timerFormat;
}
public static MineConfigBuilder builder() {
return new MineConfigBuilder();
}
// 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);
}
}

View file

@ -0,0 +1,129 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.reward.Reward;
import org.bukkit.Location;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Builder for creating immutable MineConfig instances.
*/
public final class MineConfigBuilder {
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";
MineConfigBuilder() {
}
public MineConfigBuilder selections(List<Location> selections) {
this.selections = new ArrayList<>(selections);
return this;
}
public MineConfigBuilder blocks(Map<String, Double> blocks) {
this.blocks = new HashMap<>(blocks);
return this;
}
public MineConfigBuilder fillMode(FillMode fillMode) {
this.fillMode = fillMode != null ? fillMode : FillMode.CUBOID;
return this;
}
public MineConfigBuilder maskMarkerMaterial(String maskMarkerMaterial) {
this.maskMarkerMaterial = maskMarkerMaterial != null ? maskMarkerMaterial : "pink_concrete";
return this;
}
public MineConfigBuilder maskPositions(List<Location> maskPositions) {
this.maskPositions = maskPositions != null ? new ArrayList<>(maskPositions) : new ArrayList<>();
return this;
}
public MineConfigBuilder rewards(List<Reward> rewards) {
this.rewards = new ArrayList<>(rewards);
return this;
}
public MineConfigBuilder resetTicks(int resetTicks) {
this.resetTicks = resetTicks;
return this;
}
public MineConfigBuilder resetPercent(double resetPercent) {
this.resetPercent = resetPercent;
return this;
}
public MineConfigBuilder resetOnPercentEnabled(boolean resetOnPercentEnabled) {
this.resetOnPercentEnabled = resetOnPercentEnabled;
return this;
}
public MineConfigBuilder resetCommands(List<String> resetCommands) {
this.resetCommands = new ArrayList<>(resetCommands);
return this;
}
public MineConfigBuilder broadcastReset(String broadcastReset) {
this.broadcastReset = broadcastReset;
return this;
}
public MineConfigBuilder teleportOnReset(boolean teleportOnReset) {
this.teleportOnReset = teleportOnReset;
return this;
}
public MineConfigBuilder teleportLocation(Location teleportLocation) {
this.teleportLocation = teleportLocation;
return this;
}
public MineConfigBuilder actionBarEnabled(boolean actionBarEnabled) {
this.actionBarEnabled = actionBarEnabled;
return this;
}
public MineConfigBuilder actionBarMessage(String actionBarMessage) {
this.actionBarMessage = actionBarMessage;
return this;
}
public MineConfigBuilder actionBarRange(double actionBarRange) {
this.actionBarRange = actionBarRange;
return this;
}
public MineConfigBuilder timerFormat(String timerFormat) {
this.timerFormat = timerFormat;
return this;
}
public MineConfig build() {
return new MineConfig(
selections, blocks, fillMode, maskMarkerMaterial, maskPositions,
rewards, resetTicks, resetPercent, resetOnPercentEnabled,
resetCommands, broadcastReset, teleportOnReset, teleportLocation,
actionBarEnabled, actionBarMessage, actionBarRange, timerFormat
);
}
}

View file

@ -0,0 +1,16 @@
package dev.loki.lomines.data.config.parser;
/**
* 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);
}
}

View file

@ -0,0 +1,221 @@
package dev.loki.lomines.data.config.parser;
import dev.loki.lomines.data.config.FillMode;
import dev.loki.lomines.data.reward.Reward;
import dev.loki.lomines.data.reward.RewardParser;
import dev.loki.lomines.util.location.LocationParser;
import org.bukkit.Location;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import java.util.*;
/**
* Parses YAML configuration into intermediate data structures.
* Responsible only for reading YAML and converting to Java objects.
* Does not perform validation - that's handled by ConfigValidator.
*/
public final class ConfigParser {
private final RewardParser rewardParser;
public ConfigParser(RewardParser rewardParser) {
this.rewardParser = rewardParser;
}
/**
* 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 location parsing fails
*/
public 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
);
}
}
}
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
*/
public 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 = parseWeight(key, value);
// Store block key (validation happens in ConfigValidator)
String blockKey = normalizeBlockKey(key);
blocks.put(blockKey, weight);
}
return blocks;
}
/**
* Parses fill mode from the YAML configuration.
*
* @param yaml the YAML configuration
* @return the parsed FillMode
*/
public 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;
};
}
/**
* Parses mask marker material from the YAML configuration.
*
* @param yaml the YAML configuration
* @return the mask marker material name
*/
public String parseMaskMarker(YamlConfiguration yaml) {
String marker = yaml.getString("mask.marker", "pink_concrete");
if (marker == null || marker.isBlank()) {
return "pink_concrete";
}
return marker.trim().toLowerCase(Locale.ROOT);
}
/**
* Parses mask positions from the YAML configuration.
*
* @param yaml the YAML configuration
* @return list of mask positions
* @throws ConfigParseException if position parsing fails
*/
public 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.
* Delegates to RewardParser for actual parsing logic.
*
* @param yaml the YAML configuration
* @return list of parsed Reward objects
* @throws ConfigParseException if reward configuration is invalid
*/
public List<Reward> parseRewards(YamlConfiguration yaml) throws ConfigParseException {
return rewardParser.parseRewards(yaml);
}
/**
* 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
*/
public Location parseTeleportLocation(YamlConfiguration yaml) throws ConfigParseException {
if (!yaml.contains("teleport-location")) {
return null;
}
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
);
}
}
private double parseWeight(String key, Object value) throws ConfigParseException {
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
return 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
);
}
}
private String normalizeBlockKey(String key) {
if (key.startsWith("oraxen:") || key.startsWith("itemsadder:")) {
return key;
}
return key.toLowerCase();
}
}

View 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 dev.loki.lomines.data;

View file

@ -0,0 +1,62 @@
package dev.loki.lomines.data.reward;
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 record Reward(List<Material> materials, double chance, List<ItemStack> items, List<String> commands,
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);
}
// JavaBean-style getters for compatibility
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);
}
}

View file

@ -0,0 +1,83 @@
package dev.loki.lomines.data.reward;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.util.ValidationUtils;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import java.util.Map;
/**
* Parses ItemStack objects from YAML reward item configurations.
* Handles material type, amount, and basic item properties.
*/
final class RewardItemParser {
/**
* Parses a single reward item from a map configuration.
*
* @param itemMap the item configuration map
* @param index the index of the item in the list (for error messages)
* @return the parsed ItemStack
* @throws ConfigParseException if the item configuration is invalid
*/
ItemStack parseItem(Map<String, Object> itemMap, int index) throws ConfigParseException {
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 = parseMaterial(typeName);
int amount = parseAmount(itemMap);
return new ItemStack(material, amount);
}
/**
* Parses a material from a string name.
*
* @param typeName the material name
* @return the parsed Material
* @throws ConfigParseException if the material is unknown
*/
private Material parseMaterial(String typeName) throws ConfigParseException {
try {
return ValidationUtils.validateMaterial(typeName);
} catch (IllegalArgumentException e) {
throw new ConfigParseException(
"Unknown material in item type: '" + typeName + "'",
e
);
}
}
/**
* Parses the amount field from an item configuration.
*
* @param itemMap the item configuration map
* @return the parsed amount (defaults to 1 if not specified)
* @throws ConfigParseException if the amount value is invalid
*/
private int parseAmount(Map<String, Object> itemMap) throws ConfigParseException {
if (!itemMap.containsKey("amount")) {
return 1;
}
Object amountObj = itemMap.get("amount");
try {
if (amountObj instanceof Number) {
return ((Number) amountObj).intValue();
} else if (amountObj instanceof String) {
return 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);
}
}
}

View file

@ -0,0 +1,224 @@
package dev.loki.lomines.data.reward;
import dev.loki.lomines.data.config.parser.ConfigParseException;
import dev.loki.lomines.util.ValidationUtils;
import org.bukkit.Material;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Parses reward configurations from YAML.
* Handles reward chance, materials, items, commands, and prevent-drops settings.
*/
public final class RewardParser {
private final RewardItemParser itemParser;
public RewardParser() {
this.itemParser = new RewardItemParser();
}
/**
* Parses all rewards from the YAML configuration.
*
* @param yaml the YAML configuration
* @return list of parsed Reward objects
* @throws ConfigParseException if reward configuration is invalid
*/
public List<Reward> parseRewards(YamlConfiguration yaml) throws ConfigParseException {
List<Reward> rewards = new ArrayList<>();
if (!yaml.contains("random-rewards")) {
return rewards;
}
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);
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 configuration.
*
* @param rewardMap the reward configuration map
* @return the parsed Reward object
* @throws ConfigParseException if the reward configuration is invalid
*/
private Reward parseReward(Map<String, Object> rewardMap) throws ConfigParseException {
double chance = parseRewardField(rewardMap, "chance", Double.class);
boolean preventDrops = parseRewardField(rewardMap, "prevent-drops", Boolean.class, false);
List<Material> materials = parseRewardMaterials(rewardMap);
List<ItemStack> items = parseRewardItems(rewardMap);
List<String> commands = parseRewardCommands(rewardMap);
return new Reward(materials, chance, items, commands, preventDrops);
}
@SuppressWarnings("unchecked")
private <T> T parseRewardField(Map<String, Object> map, String key, Class<T> type)
throws ConfigParseException {
if (!map.containsKey(key)) {
throw new ConfigParseException("Missing required '" + key + "' field in reward");
}
Object value = map.get(key);
if (type == Double.class) {
return (T) parseDoubleValue(key, value);
} else if (type == Boolean.class) {
if (!(value instanceof Boolean)) {
throw new ConfigParseException(
"Invalid '" + key + "' value: expected boolean, got " +
(value != null ? value.getClass().getSimpleName() : "null")
);
}
return (T) value;
}
throw new ConfigParseException("Unsupported type: " + type);
}
@SuppressWarnings("unchecked")
private <T> T parseRewardField(Map<String, Object> map, String key, Class<T> type, T defaultValue) {
if (!map.containsKey(key)) {
return defaultValue;
}
Object value = map.get(key);
if (type.isInstance(value)) {
return (T) value;
}
return defaultValue;
}
private Double parseDoubleValue(String key, Object value) throws ConfigParseException {
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
return Double.parseDouble((String) value);
} else {
throw new ConfigParseException(
"Invalid '" + key + "' type: expected number, got " +
(value != null ? value.getClass().getSimpleName() : "null")
);
}
} catch (NumberFormatException e) {
throw new ConfigParseException("Invalid '" + key + "' value: " + value, e);
}
}
private List<Material> parseRewardMaterials(Map<String, Object> rewardMap)
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;
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 = ValidationUtils.validateMaterial(blockName);
materials.add(material);
} catch (IllegalArgumentException e) {
throw new ConfigParseException(
"Unknown material in reward blocks: '" + blockName + "'",
e
);
}
}
return materials;
}
private List<ItemStack> parseRewardItems(Map<String, Object> rewardMap)
throws ConfigParseException {
List<ItemStack> items = new ArrayList<>();
if (!rewardMap.containsKey("items")) {
return items;
}
Object itemsObj = rewardMap.get("items");
if (!(itemsObj instanceof List<?> itemsList)) {
throw new ConfigParseException(
"Invalid 'items' type: expected list, got " +
(itemsObj != null ? itemsObj.getClass().getSimpleName() : "null")
);
}
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 = itemParser.parseItem(itemMap, i);
items.add(item);
}
return items;
}
private List<String> parseRewardCommands(Map<String, Object> rewardMap)
throws ConfigParseException {
List<String> commands = new ArrayList<>();
if (!rewardMap.containsKey("commands")) {
return commands;
}
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;
}
}

View file

@ -0,0 +1,104 @@
package dev.loki.lomines.data.stats;
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.
* <p>
* 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;
private final Object cacheLock = new Object();
// Cache for getTopTotal - volatile for visibility across threads
private volatile List<LeaderboardEntry> cachedTopTotal = null;
/**
* 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.count() > 0)
.sorted(Comparator.comparingLong(LeaderboardEntry::count).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).playerId().equals(playerId)) {
return i + 1; // 1-based position
}
}
return -1;
}
}

View file

@ -0,0 +1,29 @@
package dev.loki.lomines.data.stats;
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 record LeaderboardEntry(UUID playerId, long count) {
public LeaderboardEntry(UUID playerId, long count) {
this.playerId = Objects.requireNonNull(playerId, "playerId cannot be null");
this.count = 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 String toString() {
return "LeaderboardEntry{playerId=" + playerId + ", count=" + count + "}";
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.lomines.data.stats;
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 void setTotalBlocks(long value) {
totalBlocks.set(value);
}
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 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;
}
}

View file

@ -0,0 +1,181 @@
package dev.loki.lomines.data.stats;
import dev.loki.lomines.LoMinesPlugin;
import dev.lolib.scheduler.ScheduledTask;
import dev.lolib.scheduler.Scheduler;
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;
}
}

View file

@ -0,0 +1,216 @@
package dev.loki.lomines.gui;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mines;
import dev.loki.lomines.wand.GroupWandManager;
import dev.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 {
/**
* 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;
private static final int SIZE = 54;
/**
* 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));
}
}

View file

@ -0,0 +1,32 @@
package dev.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;
}
@Override
public Inventory getInventory() {
return inventory;
}
void setInventory(Inventory inventory) {
this.inventory = inventory;
}
}

View 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 dev.loki.lomines.gui;

View file

@ -0,0 +1,91 @@
package dev.loki.lomines.handler;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.util.format.TimeFormatter;
import dev.loki.lomines.util.location.Cuboid;
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);
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.lomines.handler;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.data.config.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);
}
}
}

View file

@ -0,0 +1,151 @@
package dev.loki.lomines.handler;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine;
import dev.loki.lomines.data.config.FillMode;
import dev.loki.lomines.util.location.Cuboid;
import dev.lolib.scheduler.Scheduler;
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);
}
}
}
}

View file

@ -0,0 +1,71 @@
package dev.loki.lomines.handler;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.data.config.MineConfig;
import dev.loki.lomines.data.reward.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.chance())) {
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.items()) {
player.getInventory().addItem(item.clone());
}
for (String command : reward.commands()) {
String parsed = command.replace("%player%", player.getName());
plugin.getServer().dispatchCommand(plugin.getServer().getConsoleSender(), parsed);
}
}
}

View 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 dev.loki.lomines.handler;

View file

@ -0,0 +1,74 @@
package dev.loki.lomines.integration;
import dev.loki.lomines.LoMinesPlugin;
import org.bukkit.Bukkit;
/**
* Manages integrations with external plugins.
*/
public final class IntegrationManager {
private final LoMinesPlugin plugin;
// private PlaceholderAPIIntegration placeholderAPI;
public IntegrationManager(LoMinesPlugin plugin) {
this.plugin = plugin;
}
/**
* Initializes all available integrations.
*/
public void initAll() {
// initPlaceholderAPI();
checkWorldGuard();
checkOraxen();
checkItemsAdder();
}
// private void initPlaceholderAPI() {
// if (isPluginEnabled("PlaceholderAPI")) {
// try {
// placeholderAPI = new PlaceholderAPIIntegration(plugin);
// placeholderAPI.register();
// plugin.loLogger().info("PlaceholderAPI integration enabled");
// } catch (Exception e) {
// plugin.loLogger().warn("Failed to enable PlaceholderAPI integration: " + e.getMessage());
// }
// }
// }
private void checkWorldGuard() {
if (isPluginEnabled("WorldGuard")) {
plugin.loLogger().info("WorldGuard detected (integration not yet implemented)");
}
}
private void checkOraxen() {
if (isPluginEnabled("Oraxen")) {
plugin.loLogger().info("Oraxen integration available");
}
}
private void checkItemsAdder() {
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);
}
/**
* Unregisters all integrations.
*/
public void shutdown() {
// if (placeholderAPI != null) {
// placeholderAPI.unregister();
// }
}
}

View 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 dev.loki.lomines.integration;

View file

@ -0,0 +1,35 @@
package dev.loki.lomines.listener;
import dev.loki.lomines.LoMinesPlugin;
import dev.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());
}
}
}
}

View file

@ -0,0 +1,51 @@
package dev.loki.lomines.listener;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.gui.GroupCreateGui;
import dev.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);
}
}
}

View file

@ -0,0 +1,73 @@
package dev.loki.lomines.listener;
import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.gui.GroupCreateGui;
import dev.loki.lomines.wand.GroupWandItem;
import dev.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));
}
}
}

View file

@ -0,0 +1,25 @@
package dev.loki.lomines.listener;
import dev.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());
}
}

View 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 dev.loki.lomines.listener;

View file

@ -0,0 +1,84 @@
package dev.loki.lomines.util;
import org.bukkit.command.CommandSender;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Унифицированная обработка ошибок и отправка сообщений
*/
public class ErrorHandler {
private final Logger logger;
private final String prefix;
public ErrorHandler(Logger logger, String prefix) {
this.logger = logger;
this.prefix = prefix;
}
/**
* Отправить сообщение об ошибке игроку
*/
public void sendError(CommandSender sender, String message) {
sender.sendMessage(MessageFormatter.error(message));
}
/**
* Отправить сообщение об ошибке игроку с причиной
*/
public void sendError(CommandSender sender, String message, String reason) {
sender.sendMessage(MessageFormatter.error(message + ": " + reason));
}
/**
* Залогировать ошибку
*/
public void logError(String message) {
logger.log(Level.SEVERE, prefix + message);
}
/**
* Залогировать ошибку с исключением
*/
public void logError(String message, Throwable throwable) {
logger.log(Level.SEVERE, prefix + message, throwable);
}
/**
* Отправить ошибку игроку и залогировать
*/
public void handleError(CommandSender sender, String userMessage, String logMessage) {
sendError(sender, userMessage);
logError(logMessage);
}
/**
* Отправить ошибку игроку и залогировать с исключением
*/
public void handleError(CommandSender sender, String userMessage, String logMessage, Throwable throwable) {
sendError(sender, userMessage);
logError(logMessage, throwable);
}
/**
* Обработать ошибку валидации
*/
public void handleValidationError(CommandSender sender, String fieldName, String value) {
sendError(sender, "Некорректное значение для " + fieldName + ": " + value);
}
/**
* Обработать ошибку "не найдено"
*/
public void handleNotFound(CommandSender sender, String entityType, String identifier) {
sendError(sender, entityType + " '" + identifier + "' не найден");
}
/**
* Обработать ошибку доступа
*/
public void handlePermissionDenied(CommandSender sender, String action) {
sendError(sender, "У вас нет прав для: " + action);
}
}

View file

@ -0,0 +1,59 @@
package dev.loki.lomines.util;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
/**
* Utility class for formatting messages with consistent styling.
*/
public final class MessageFormatter {
private static final String PREFIX = "[LoMines] ";
private MessageFormatter() {
}
/**
* Creates a success message (green).
*/
public static Component success(String message) {
return Component.text(PREFIX + message, NamedTextColor.GREEN);
}
/**
* Creates an error message (red).
*/
public static Component error(String message) {
return Component.text(PREFIX + message, NamedTextColor.RED);
}
/**
* Creates a warning message (yellow).
*/
public static Component warning(String message) {
return Component.text(PREFIX + message, NamedTextColor.YELLOW);
}
/**
* Creates an info message (gray).
*/
public static Component info(String message) {
return Component.text(PREFIX + message, NamedTextColor.GRAY);
}
/**
* Creates a highlighted message (aqua, bold).
*/
public static Component highlight(String message) {
return Component.text(message, NamedTextColor.AQUA)
.decoration(TextDecoration.BOLD, true);
}
/**
* Creates a plain message without prefix.
*/
public static Component plain(String message) {
return Component.text(message);
}
}

View file

@ -0,0 +1,62 @@
package dev.loki.lomines.util;
import org.bukkit.Material;
import java.util.Optional;
/**
* Utility class for validation operations.
*/
public final class ValidationUtils {
private ValidationUtils() {
}
/**
* Validates and parses a material name.
*
* @param materialName the material name to validate
* @return the Material if valid
* @throws IllegalArgumentException if material is invalid
*/
public static Material validateMaterial(String materialName) {
if (materialName == null || materialName.isBlank()) {
throw new IllegalArgumentException("Material name cannot be null or empty");
}
try {
return Material.valueOf(materialName.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid material: " + materialName);
}
}
/**
* Safely parses a material name, returning Optional.
*
* @param materialName the material name to parse
* @return Optional containing the Material if valid, empty otherwise
*/
public static Optional<Material> parseMaterial(String materialName) {
if (materialName == null || materialName.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(Material.valueOf(materialName.toUpperCase()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
/**
* Parses a material name with a fallback default.
*
* @param materialName the material name to parse
* @param defaultMaterial the default material if parsing fails
* @return the parsed Material or the default
*/
public static Material parseMaterialOrDefault(String materialName, Material defaultMaterial) {
return parseMaterial(materialName).orElse(defaultMaterial);
}
}

View file

@ -0,0 +1,174 @@
package dev.loki.lomines.util.format;
import dev.loki.lomines.util.location.Cuboid;
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 record ChunkCoordinate(int x, int z) {
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof ChunkCoordinate other)) return false;
return x == other.x && z == other.z;
}
}
}

View file

@ -0,0 +1,67 @@
package dev.loki.lomines.util.format;
/**
* 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);
}
}

View file

@ -0,0 +1,21 @@
package dev.loki.lomines.util.location;
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();
}
}

View file

@ -0,0 +1,181 @@
package dev.loki.lomines.util.location;
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 other)) return false;
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;
}
}

View file

@ -0,0 +1,96 @@
package dev.loki.lomines.util.location;
import dev.loki.lomines.data.config.parser.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()
);
}
}

View 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 dev.loki.lomines.util;

View file

@ -0,0 +1,46 @@
package dev.loki.lomines.util.selection;
import dev.loki.lomines.util.location.Cuboid;
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;
}
}

View file

@ -0,0 +1,139 @@
package dev.loki.lomines.util.selection;
import dev.loki.lomines.util.location.Cuboid;
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
);
}
}
}

View file

@ -0,0 +1,86 @@
package dev.loki.lomines.util.selection;
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");
}
}
}

View file

@ -0,0 +1,61 @@
package dev.loki.lomines.wand;
import dev.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);
}
}

View file

@ -0,0 +1,21 @@
package dev.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);
}
}

View file

@ -0,0 +1,81 @@
package dev.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 final Location[] pos1 = new Location[SLOTS];
private final Location[] pos2 = new Location[SLOTS];
private String baseName = "";
private int activeSlot;
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;
}
}