refactor(config): complete rewrite of configuration system

BREAKING CHANGE: New section-based configuration format

New Features:
- Type-safe BlockKey sealed interface (Vanilla, Oraxen, ItemsAdder)
- Section-based config: RegionConfig, BlockConfig, ResetConfig, RewardConfig, UIConfig, TeleportConfig
- Human-readable durations: 5m, 30s, 2h, 1d
- YAML inheritance from defaults.yml
- MiniMessage support for action bar and item names
- Immutable config records with validation

Removed:
- Legacy MineConfig (17-field god object)
- ConfigParser, ConfigValidator, ConfigSerializer split classes
- Legacy FillMode enum (replaced with better version)
- Old ConfigLoader with complex parsing logic

New Files:
- data/config/block/BlockKey.java - sealed interface for block types
- data/config/block/BlockConfig.java - type-safe block weights
- data/config/block/FillMode.java - fill strategies
- data/config/region/RegionConfig.java - region management
- data/config/reset/ResetConfig.java - reset behavior with Duration
- data/config/reward/RewardConfig.java - rewards with ItemReward
- data/config/teleport/TeleportConfig.java - teleport on reset
- data/config/ui/UIConfig.java - action bar and messages
- data/config/MineConfig.java - composed from sections
- data/config/ConfigLoader.java - clean loader with defaults inheritance
- defaults.yml - default configuration template

Tests:
- BlockKeyTest.java - serialization/deserialization
- BlockConfigTest.java - weight normalization, validation
- ResetConfigTest.java - duration parsing, defaults
- UIConfigTest.java - timer formatting, action bar
- MineConfigTest.java - builder, defaults, validation
This commit is contained in:
loki5512344 2026-05-31 21:57:10 +02:00
parent f4e9458544
commit f1060f34d4
22 changed files with 2012 additions and 896 deletions

View file

@ -1,111 +1,427 @@
package dev.loki.lomines.data.config; package dev.loki.lomines.data.config;
import dev.loki.lomines.data.config.parser.ConfigParseException; import dev.loki.lomines.data.config.block.BlockConfig;
import dev.loki.lomines.data.config.parser.ConfigParser; import dev.loki.lomines.data.config.block.BlockKey;
import dev.loki.lomines.data.reward.Reward; import dev.loki.lomines.data.config.block.FillMode;
import dev.loki.lomines.data.reward.RewardParser; import dev.loki.lomines.data.config.region.RegionConfig;
import dev.loki.lomines.data.config.reset.ResetConfig;
import dev.loki.lomines.data.config.reward.RewardConfig;
import dev.loki.lomines.data.config.reward.RewardConfig.RewardEntry;
import dev.loki.lomines.data.config.reward.RewardConfig.RewardEntry.ItemReward;
import dev.loki.lomines.data.config.teleport.TeleportConfig;
import dev.loki.lomines.data.config.ui.UIConfig;
import dev.loki.lomines.util.location.Cuboid;
import dev.loki.lomines.util.location.LocationParser;
import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.time.Duration;
import java.util.Map; import java.util.*;
/** /**
* Facade for loading and saving MineConfig objects from/to YAML files. * Clean config loader for section-based configuration.
* Coordinates the work between ConfigParser, ConfigValidator, and ConfigSerializer. * Supports YAML inheritance from defaults.
* Follows the Single Responsibility Principle by delegating specific tasks.
*/ */
public final class ConfigLoader { public final class ConfigLoader {
private final ConfigParser parser; private final Path dataFolder;
private final ConfigValidator validator; private YamlConfiguration defaults;
private final ConfigSerializer serializer;
public ConfigLoader() { public ConfigLoader(Path dataFolder) {
RewardParser rewardParser = new RewardParser(); this.dataFolder = dataFolder;
this.parser = new ConfigParser(rewardParser); loadDefaults();
this.validator = new ConfigValidator();
this.serializer = new ConfigSerializer();
} }
/** /**
* Loads a MineConfig from a YAML file. * Load or create defaults.yml.
* 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 { private void loadDefaults() {
if (configFile == null) { Path defaultsPath = dataFolder.resolve("mines").resolve("_defaults.yml");
throw new ConfigParseException("Config file path cannot be null"); defaults = new YamlConfiguration();
if (Files.exists(defaultsPath)) {
defaults = YamlConfiguration.loadConfiguration(defaultsPath.toFile());
} else {
// Create defaults with sensible values
setDefaults(defaults);
try {
Files.createDirectories(defaultsPath.getParent());
defaults.save(defaultsPath.toFile());
} catch (IOException e) {
// Ignore, use in-memory defaults
}
}
}
private void setDefaults(YamlConfiguration yaml) {
yaml.set("reset.interval", "5m");
yaml.set("reset.percent-trigger", 10.0);
yaml.set("reset.percent-enabled", false);
yaml.set("reset.commands", List.of());
yaml.set("reset.broadcast", "");
yaml.set("ui.actionbar.enabled", true);
yaml.set("ui.actionbar.format", "<green>{mine}</green> <gray>{percent}%</gray> <dark_gray>({time})");
yaml.set("ui.actionbar.range", 50.0);
yaml.set("ui.timer-format", "mm:ss");
yaml.set("teleport.enabled", false);
yaml.set("rewards", List.of());
}
/**
* Load a mine configuration from file.
*/
public MineConfig load(String mineName) throws ConfigLoadException {
Path configPath = dataFolder.resolve("mines").resolve(mineName + ".yml");
if (!Files.exists(configPath)) {
throw new ConfigLoadException("Mine not found: " + mineName);
} }
if (!configFile.toFile().exists()) { YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configPath.toFile());
throw new IOException("Config file does not exist: " + configFile);
// Merge with defaults (YAML inheritance)
mergeDefaults(yaml);
try {
return MineConfig.builder(mineName)
.region(parseRegion(yaml))
.blocks(parseBlocks(yaml))
.reset(parseReset(yaml))
.rewards(parseRewards(yaml))
.teleport(parseTeleport(yaml))
.ui(parseUI(yaml))
.build();
} catch (Exception e) {
throw new ConfigLoadException("Failed to load mine '" + mineName + "': " + e.getMessage(), e);
}
}
/**
* Save a mine configuration to file.
*/
public void save(MineConfig config) throws ConfigLoadException {
Path configPath = dataFolder.resolve("mines").resolve(config.name() + ".yml");
YamlConfiguration yaml = new YamlConfiguration();
// Save regions
saveRegion(yaml, config.region());
// Save blocks
saveBlocks(yaml, config.blocks());
// Save reset
saveReset(yaml, config.reset());
// Save rewards
saveRewards(yaml, config.rewards());
// Save teleport
saveTeleport(yaml, config.teleport());
// Save UI
saveUI(yaml, config.ui());
try {
Files.createDirectories(configPath.getParent());
yaml.save(configPath.toFile());
} catch (IOException e) {
throw new ConfigLoadException("Failed to save mine: " + e.getMessage(), e);
}
}
// --- Parsers ---
private RegionConfig parseRegion(YamlConfiguration yaml) {
List<Location> selections = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
String key = "region.selection." + i;
if (yaml.contains(key)) {
String locStr = yaml.getString(key);
if (locStr != null && !locStr.isBlank()) {
selections.add(parseLocation(locStr));
}
}
} }
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configFile.toFile()); if (selections.isEmpty()) {
// Legacy fallback
for (int i = 1; i <= 10; i++) {
String key = "selection." + i;
if (yaml.contains(key)) {
String locStr = yaml.getString(key);
if (locStr != null && !locStr.isBlank()) {
selections.add(parseLocation(locStr));
}
}
}
}
// Parse all sections if (selections.size() < 2) {
List<Location> selections = parser.parseSelections(yaml); throw new IllegalArgumentException("Mine must have at least 2 selection points (1 region)");
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 return RegionConfig.fromSelections(selections);
validator.validateSelections(selections); }
validator.validateBlocks(blocks);
validator.validateMaskMarker(maskMarker);
validator.validateRewards(rewards);
// Build and return config private BlockConfig parseBlocks(YamlConfiguration yaml) {
return MineConfig.builder() ConfigurationSection contents = yaml.getConfigurationSection("blocks.contents");
.selections(selections) if (contents == null) {
.blocks(blocks) contents = yaml.getConfigurationSection("contents"); // Legacy fallback
.fillMode(fillMode) }
.maskMarkerMaterial(maskMarker)
.maskPositions(maskPositions) if (contents == null) {
.rewards(rewards) throw new IllegalArgumentException("Missing 'blocks.contents' section");
.resetTicks(yaml.getInt("reset.ticks", 6000)) }
.resetPercent(yaml.getDouble("reset.percent", 10.0))
.resetOnPercentEnabled(yaml.getBoolean("reset-on-percent", false)) Map<BlockKey, Double> weights = new HashMap<>();
.actionBarEnabled(yaml.getBoolean("actionbar.enabled", true)) for (String key : contents.getKeys(false)) {
.actionBarMessage(yaml.getString("actionbar.message", "")) double weight = contents.getDouble(key);
.actionBarRange(yaml.getDouble("actionbar.range", 50.0)) if (weight <= 0) continue;
.timerFormat(yaml.getString("timer-format", "mm:ss")) weights.put(BlockKey.deserialize(key), weight);
.teleportOnReset(yaml.getBoolean("teleport-on-reset", false)) }
.teleportLocation(teleportLocation)
.resetCommands(yaml.getStringList("reset-commands")) FillMode fillMode = FillMode.valueOf(
.broadcastReset(yaml.getString("broadcast-reset", "")) yaml.getString("blocks.fill-mode", "CUBOID").toUpperCase()
);
// Parse mask config if applicable
BlockConfig.MaskConfig mask = null;
if (fillMode == FillMode.MASK) {
String markerStr = yaml.getString("blocks.mask.marker", "pink_concrete");
BlockKey marker = BlockKey.deserialize(markerStr);
Map<String, Boolean> positions = new HashMap<>();
List<String> posList = yaml.getStringList("blocks.mask.positions");
for (String pos : posList) {
positions.put(pos, true);
}
mask = new BlockConfig.MaskConfig(marker, positions);
}
return new BlockConfig(weights, fillMode, mask);
}
private ResetConfig parseReset(YamlConfiguration yaml) {
String intervalStr = yaml.getString("reset.interval", defaults.getString("reset.interval", "5m"));
return ResetConfig.builder()
.interval(intervalStr)
.percentTrigger(yaml.getDouble("reset.percent-trigger", defaults.getDouble("reset.percent-trigger", 10.0)))
.percentEnabled(yaml.getBoolean("reset.percent-enabled", defaults.getBoolean("reset.percent-enabled", false)))
.commands(yaml.getStringList("reset.commands"))
.broadcastMessage(yaml.getString("reset.broadcast", ""))
.build(); .build();
} }
/** private RewardConfig parseRewards(YamlConfiguration yaml) {
* Saves a MineConfig to a YAML file. List<RewardEntry> entries = new ArrayList<>();
* Serializes the config and writes it to the specified file. List<Map<?, ?>> rewardList = yaml.getMapList("rewards");
*
* @param config the MineConfig to save if (rewardList.isEmpty()) {
* @param configFile the path to the YAML configuration file rewardList = yaml.getMapList("random-rewards"); // Legacy fallback
* @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(); for (Map<?, ?> map : rewardList) {
serializer.serialize(config, yaml); entries.add(parseRewardEntry(map));
yaml.save(configFile.toFile()); }
return new RewardConfig(entries);
}
private RewardEntry parseRewardEntry(Map<?, ?> map) {
double chance = ((Number) map.getOrDefault("chance", 0)).doubleValue();
List<BlockKey> blocks = new ArrayList<>();
List<?> blockList = (List<?>) map.get("blocks");
if (blockList != null) {
for (Object b : blockList) {
blocks.add(BlockKey.deserialize(b.toString()));
}
}
List<ItemReward> items = new ArrayList<>();
List<?> itemList = (List<?>) map.get("items");
if (itemList != null) {
for (Object i : itemList) {
@SuppressWarnings("unchecked")
Map<String, Object> itemMap = (Map<String, Object>) i;
items.add(parseItemReward(itemMap));
}
}
List<String> commands = (List<String>) map.getOrDefault("commands", List.of());
boolean preventDrops = (Boolean) map.getOrDefault("prevent-drops", false);
return new RewardEntry(blocks, chance, items, commands, preventDrops);
}
private ItemReward parseItemReward(Map<String, Object> map) {
String typeStr = (String) map.get("type");
Material material = Material.matchMaterial(typeStr);
if (material == null) {
material = Material.STONE;
}
int amount = ((Number) map.getOrDefault("amount", 1)).intValue();
String name = (String) map.get("name");
@SuppressWarnings("unchecked")
List<String> lore = (List<String>) map.getOrDefault("lore", List.of());
return new ItemReward(material, amount, name, lore);
}
private TeleportConfig parseTeleport(YamlConfiguration yaml) {
boolean enabled = yaml.getBoolean("teleport.enabled", false);
if (!enabled) {
return TeleportConfig.disabled();
}
String locStr = yaml.getString("teleport.location");
if (locStr == null) {
return TeleportConfig.disabled();
}
return TeleportConfig.at(parseLocation(locStr));
}
private UIConfig parseUI(YamlConfiguration yaml) {
boolean actionBarEnabled = yaml.getBoolean("ui.actionbar.enabled",
defaults.getBoolean("ui.actionbar.enabled", true));
return new UIConfig(
actionBarEnabled,
yaml.getString("ui.actionbar.format", UIConfig.DEFAULT_ACTIONBAR_FORMAT),
yaml.getDouble("ui.actionbar.range", 50.0),
yaml.getString("ui.timer-format", UIConfig.DEFAULT_TIMER_FORMAT)
);
}
// --- Savers ---
private void saveRegion(YamlConfiguration yaml, RegionConfig region) {
int i = 1;
for (Cuboid cuboid : region.regions()) {
yaml.set("region.selection." + i, LocationParser.format(cuboid.getMin()));
i++;
yaml.set("region.selection." + i, LocationParser.format(cuboid.getMax()));
i++;
}
}
private void saveBlocks(YamlConfiguration yaml, BlockConfig blocks) {
for (var entry : blocks.weights().entrySet()) {
yaml.set("blocks.contents." + entry.getKey().serialize(), entry.getValue());
}
yaml.set("blocks.fill-mode", blocks.fillMode().name().toLowerCase());
if (blocks.fillMode() == FillMode.MASK && blocks.mask() != null) {
yaml.set("blocks.mask.marker", blocks.mask().marker().serialize());
yaml.set("blocks.mask.positions", new ArrayList<>(blocks.mask().positions().keySet()));
}
}
private void saveReset(YamlConfiguration yaml, ResetConfig reset) {
yaml.set("reset.interval", reset.intervalDisplay());
yaml.set("reset.percent-trigger", reset.percentTrigger());
yaml.set("reset.percent-enabled", reset.percentEnabled());
yaml.set("reset.commands", reset.commands());
yaml.set("reset.broadcast", reset.broadcastMessage());
}
private void saveRewards(YamlConfiguration yaml, RewardConfig rewards) {
List<Map<String, Object>> list = new ArrayList<>();
for (RewardEntry entry : rewards.entries()) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("chance", entry.chance());
map.put("blocks", entry.blocks().stream().map(BlockKey::serialize).toList());
List<Map<String, Object>> items = new ArrayList<>();
for (ItemReward item : entry.items()) {
Map<String, Object> itemMap = new LinkedHashMap<>();
itemMap.put("type", item.material().name().toLowerCase());
itemMap.put("amount", item.amount());
if (item.displayName() != null) {
itemMap.put("name", item.displayName());
}
if (!item.lore().isEmpty()) {
itemMap.put("lore", item.lore());
}
items.add(itemMap);
}
map.put("items", items);
map.put("commands", entry.commands());
map.put("prevent-drops", entry.preventVanillaDrops());
list.add(map);
}
yaml.set("rewards", list);
}
private void saveTeleport(YamlConfiguration yaml, TeleportConfig teleport) {
yaml.set("teleport.enabled", teleport.enabled());
teleport.getLocation().ifPresent(loc ->
yaml.set("teleport.location", LocationParser.format(loc)));
}
private void saveUI(YamlConfiguration yaml, UIConfig ui) {
yaml.set("ui.actionbar.enabled", ui.actionBarEnabled());
yaml.set("ui.actionbar.format", ui.actionBarFormat());
yaml.set("ui.actionbar.range", ui.actionBarRange());
yaml.set("ui.timer-format", ui.timerFormat());
}
// --- Helpers ---
private Location parseLocation(String str) {
String[] parts = str.split(";");
if (parts.length < 4) {
throw new IllegalArgumentException("Invalid location format: " + str);
}
World world = Bukkit.getWorld(parts[0]);
if (world == null) {
throw new IllegalArgumentException("Unknown world: " + parts[0]);
}
double x = Double.parseDouble(parts[1]);
double y = Double.parseDouble(parts[2]);
double z = Double.parseDouble(parts[3]);
float yaw = parts.length > 4 ? Float.parseFloat(parts[4]) : 0;
float pitch = parts.length > 5 ? Float.parseFloat(parts[5]) : 0;
return new Location(world, x, y, z, yaw, pitch);
}
private void mergeDefaults(YamlConfiguration yaml) {
// Simple shallow merge - could be enhanced for deep merging
for (String key : defaults.getKeys(true)) {
if (!yaml.contains(key)) {
yaml.set(key, defaults.get(key));
}
}
}
/**
* Exception for config loading errors.
*/
public static class ConfigLoadException extends Exception {
public ConfigLoadException(String message) {
super(message);
}
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
} }
} }

View file

@ -1,142 +0,0 @@
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

@ -1,138 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,189 +1,126 @@
package dev.loki.lomines.data.config; package dev.loki.lomines.data.config;
import dev.loki.lomines.data.reward.Reward; import dev.loki.lomines.data.config.block.BlockConfig;
import org.bukkit.Location; import dev.loki.lomines.data.config.region.RegionConfig;
import dev.loki.lomines.data.config.reset.ResetConfig;
import dev.loki.lomines.data.config.reward.RewardConfig;
import dev.loki.lomines.data.config.teleport.TeleportConfig;
import dev.loki.lomines.data.config.ui.UIConfig;
import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
/** /**
* Immutable configuration data model for a mine. * Clean, type-safe, section-based mine configuration.
* All fields are final to ensure immutability. * Proper separation of concerns: each section handles one aspect.
* Use the Builder pattern to create instances.
*/ */
public final class MineConfig { public record MineConfig(
String name,
RegionConfig region,
BlockConfig blocks,
ResetConfig reset,
RewardConfig rewards,
TeleportConfig teleport,
UIConfig ui
) {
// Regions public MineConfig {
private final List<Location> selections; Objects.requireNonNull(name, "Mine name cannot be null");
if (name.isBlank()) {
throw new IllegalArgumentException("Mine name cannot be blank");
}
name = name.toLowerCase().trim();
// Blocks Objects.requireNonNull(region, "Region config cannot be null");
private final Map<String, Double> blocks; Objects.requireNonNull(blocks, "Block config cannot be null");
Objects.requireNonNull(reset, "Reset config cannot be null");
/** Objects.requireNonNull(rewards, "Reward config cannot be null");
* How blocks are placed on reset. Objects.requireNonNull(teleport, "Teleport config cannot be null");
*/ Objects.requireNonNull(ui, "UI config cannot be null");
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}. * Total volume of the mine (for progress calculation).
*/ */
public String getMaskMarkerMaterial() { public int volume() {
return maskMarkerMaterial; return region.totalVolume();
} }
public List<Location> getMaskPositions() { /**
return maskPositions; * World name where the mine is located.
*/
public String worldName() {
return region.worldName();
} }
public List<Reward> getRewards() { /**
return rewards; * Creates builder for fluent construction.
*/
public static Builder builder(String name) {
return new Builder(name);
} }
public int getResetTicks() { /**
return resetTicks; * Creates config with sensible defaults.
*/
public static MineConfig defaults(String name, RegionConfig region, BlockConfig blocks) {
return new MineConfig(
name,
region,
blocks,
ResetConfig.defaults(),
RewardConfig.empty(),
TeleportConfig.disabled(),
UIConfig.defaults()
);
} }
public double getResetPercent() { // --- Builder ---
return resetPercent;
}
public boolean isResetOnPercentEnabled() { public static class Builder {
return resetOnPercentEnabled; private final String name;
} private RegionConfig region;
private BlockConfig blocks;
private ResetConfig reset = ResetConfig.defaults();
private RewardConfig rewards = RewardConfig.empty();
private TeleportConfig teleport = TeleportConfig.disabled();
private UIConfig ui = UIConfig.defaults();
public List<String> getResetCommands() { private Builder(String name) {
return resetCommands; this.name = name;
} }
public String getBroadcastReset() { public Builder region(RegionConfig region) {
return broadcastReset; this.region = region;
} return this;
}
public boolean isTeleportOnReset() { public Builder blocks(BlockConfig blocks) {
return teleportOnReset; this.blocks = blocks;
} return this;
}
public Location getTeleportLocation() { public Builder reset(ResetConfig reset) {
return teleportLocation; this.reset = reset;
} return this;
}
public boolean isActionBarEnabled() { public Builder rewards(RewardConfig rewards) {
return actionBarEnabled; this.rewards = rewards;
} return this;
}
public String getActionBarMessage() { public Builder teleport(TeleportConfig teleport) {
return actionBarMessage; this.teleport = teleport;
} return this;
}
public double getActionBarRange() { public Builder ui(UIConfig ui) {
return actionBarRange; this.ui = ui;
} return this;
}
public String getTimerFormat() { public MineConfig build() {
return timerFormat; return new MineConfig(name, region, blocks, reset, rewards, teleport, ui);
} }
@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

@ -1,129 +0,0 @@
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,98 @@
package dev.loki.lomines.data.config.block;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* Configuration for mine block contents.
* Type-safe, immutable, with validation.
*/
public record BlockConfig(
Map<BlockKey, Double> weights,
FillMode fillMode,
MaskConfig mask
) {
public BlockConfig {
// Defensive copy and validation
Map<BlockKey, Double> copy = new HashMap<>();
double totalWeight = 0;
for (var entry : weights.entrySet()) {
BlockKey key = Objects.requireNonNull(entry.getKey(), "Block key cannot be null");
Double weight = entry.getValue();
if (weight == null || weight <= 0 || !Double.isFinite(weight)) {
throw new IllegalArgumentException(
"Invalid weight for " + key.serialize() + ": " + weight + " (must be positive finite number)"
);
}
totalWeight += weight;
copy.put(key, weight);
}
if (copy.isEmpty()) {
throw new IllegalArgumentException("Block config must have at least one block");
}
// Normalize weights to sum to 1.0
if (Math.abs(totalWeight - 1.0) > 0.0001 && totalWeight > 0) {
double finalTotal = totalWeight;
copy.replaceAll((k, v) -> v / finalTotal);
}
weights = Collections.unmodifiableMap(new HashMap<>(copy));
fillMode = Objects.requireNonNull(fillMode, "FillMode cannot be null");
mask = mask != null ? mask : new MaskConfig(null, Map.of());
}
/**
* Creates a simple vanilla-only config.
*/
public static BlockConfig vanilla(Map<Material, Double> materials) {
Map<BlockKey, Double> keys = new HashMap<>();
for (var entry : materials.entrySet()) {
keys.put(new BlockKey.Vanilla(entry.getKey()), entry.getValue());
}
return new BlockConfig(keys, FillMode.CUBOID, null);
}
/**
* Gets the weight for a specific block key.
*/
public double weightFor(BlockKey key) {
return weights.getOrDefault(key, 0.0);
}
/**
* Total number of unique blocks.
*/
public int blockCount() {
return weights.size();
}
/**
* Whether this config uses custom blocks (Oraxen/ItemsAdder).
*/
public boolean hasCustomBlocks() {
return weights.keySet().stream()
.anyMatch(k -> k instanceof BlockKey.Oraxen || k instanceof BlockKey.ItemsAdder);
}
/**
* Configuration for mask fill mode.
*/
public record MaskConfig(
BlockKey marker,
Map<String, Boolean> positions // serialized location -> enabled
) {
public MaskConfig {
positions = positions != null
? Collections.unmodifiableMap(new HashMap<>(positions))
: Map.of();
}
}
}

View file

@ -0,0 +1,89 @@
package dev.loki.lomines.data.config.block;
import org.bukkit.Material;
import java.util.Objects;
/**
* Type-safe key for identifying blocks across different plugins.
* Supports vanilla blocks, Oraxen, and ItemsAdder.
*/
public sealed interface BlockKey permits BlockKey.Vanilla, BlockKey.Oraxen, BlockKey.ItemsAdder {
/**
* Serializes this key to string format for YAML storage.
* Format: "stone" (vanilla), "oraxen:my_block", "itemsadder:custom_block"
*/
String serialize();
/**
* Deserializes a string to appropriate BlockKey type.
*
* @throws IllegalArgumentException if format is invalid
*/
static BlockKey deserialize(String key) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("Block key cannot be null or empty");
}
String trimmed = key.trim().toLowerCase();
if (trimmed.startsWith("oraxen:")) {
return new Oraxen(trimmed.substring(7));
}
if (trimmed.startsWith("itemsadder:")) {
return new ItemsAdder(trimmed.substring(11));
}
// Vanilla block
Material material = Material.matchMaterial(trimmed);
if (material == null || !material.isBlock()) {
throw new IllegalArgumentException("Unknown vanilla block: " + key);
}
return new Vanilla(material);
}
// --- Implementations ---
record Vanilla(Material material) implements BlockKey {
public Vanilla {
Objects.requireNonNull(material, "Material cannot be null");
if (!material.isBlock()) {
throw new IllegalArgumentException(material + " is not a block");
}
}
@Override
public String serialize() {
return material.name().toLowerCase();
}
}
record Oraxen(String id) implements BlockKey {
public Oraxen {
Objects.requireNonNull(id, "Oraxen id cannot be null");
if (id.isBlank()) {
throw new IllegalArgumentException("Oraxen id cannot be blank");
}
}
@Override
public String serialize() {
return "oraxen:" + id;
}
}
record ItemsAdder(String id) implements BlockKey {
public ItemsAdder {
Objects.requireNonNull(id, "ItemsAdder id cannot be null");
if (id.isBlank()) {
throw new IllegalArgumentException("ItemsAdder id cannot be blank");
}
}
@Override
public String serialize() {
return "itemsadder:" + id;
}
}
}

View file

@ -0,0 +1,17 @@
package dev.loki.lomines.data.config.block;
/**
* Strategy for placing blocks when resetting a mine.
*/
public enum FillMode {
/**
* Fill entire cuboid region with blocks.
*/
CUBOID,
/**
* Fill only specific positions marked by a block type.
* Allows custom shapes (spheres, toruses, etc.).
*/
MASK
}

View file

@ -1,16 +0,0 @@
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

@ -1,221 +0,0 @@
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,99 @@
package dev.loki.lomines.data.config.region;
import dev.loki.lomines.util.location.Cuboid;
import org.bukkit.Location;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Configuration for mine regions.
* Immutable, validates regions on construction.
*/
public record RegionConfig(List<Cuboid> regions) {
public RegionConfig {
if (regions == null || regions.isEmpty()) {
throw new IllegalArgumentException("Mine must have at least one region");
}
// Defensive copy and validate
List<Cuboid> copy = List.copyOf(regions);
// Check all regions are in same world
String worldName = copy.get(0).getWorld().getName();
for (Cuboid region : copy) {
if (!region.getWorld().getName().equals(worldName)) {
throw new IllegalArgumentException(
"All regions must be in the same world. Expected: " + worldName
);
}
}
regions = Collections.unmodifiableList(copy);
}
/**
* Creates a single-region config.
*/
public static RegionConfig single(Cuboid region) {
return new RegionConfig(List.of(region));
}
/**
* Creates from paired locations (selection.1 + selection.2 = region 1).
* Selections must be even number.
*/
public static RegionConfig fromSelections(List<Location> selections) {
if (selections.size() % 2 != 0) {
throw new IllegalArgumentException(
"Selections must be in pairs (even number). Got: " + selections.size()
);
}
var regions = new java.util.ArrayList<Cuboid>();
for (int i = 0; i < selections.size(); i += 2) {
Location loc1 = selections.get(i);
Location loc2 = selections.get(i + 1);
if (!Objects.equals(loc1.getWorld(), loc2.getWorld())) {
throw new IllegalArgumentException(
"Selection pair " + (i / 2 + 1) + " has locations in different worlds"
);
}
regions.add(new Cuboid(loc1, loc2));
}
return new RegionConfig(regions);
}
/**
* Total volume of all regions.
*/
public int totalVolume() {
return regions.stream().mapToInt(Cuboid::getVolume).sum();
}
/**
* World name (all regions share the same world).
*/
public String worldName() {
return regions.get(0).getWorld().getName();
}
/**
* Checks if location is within any region.
*/
public boolean contains(Location location) {
return regions.stream().anyMatch(r -> r.contains(location));
}
/**
* Number of regions.
*/
public int regionCount() {
return regions.size();
}
}

View file

@ -0,0 +1,158 @@
package dev.loki.lomines.data.config.reset;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Configuration for mine reset behavior.
*/
public record ResetConfig(
Duration interval,
double percentTrigger,
boolean percentEnabled,
List<String> commands,
String broadcastMessage
) {
public static final Duration DEFAULT_INTERVAL = Duration.ofMinutes(5);
public static final double DEFAULT_PERCENT_TRIGGER = 10.0;
public ResetConfig {
// Normalize and validate interval
if (interval == null || interval.isZero() || interval.isNegative()) {
interval = DEFAULT_INTERVAL;
}
// Ensure interval is reasonable (1 second to 24 hours)
if (interval.getSeconds() < 1) {
interval = Duration.ofSeconds(1);
} else if (interval.getSeconds() > 86400) {
interval = Duration.ofHours(24);
}
// Validate percent trigger
if (percentTrigger < 0 || percentTrigger > 100) {
percentTrigger = DEFAULT_PERCENT_TRIGGER;
}
commands = commands != null ? List.copyOf(commands) : List.of();
broadcastMessage = broadcastMessage != null ? broadcastMessage : "";
}
/**
* Interval in ticks (20 ticks = 1 second).
*/
public long intervalTicks() {
return interval.getSeconds() * 20;
}
/**
* Human-readable interval string.
*/
public String intervalDisplay() {
long seconds = interval.getSeconds();
if (seconds < 60) {
return seconds + "s";
} else if (seconds < 3600) {
return (seconds / 60) + "m";
} else {
return (seconds / 3600) + "h";
}
}
/**
* Whether reset should trigger based on percent mined.
*/
public boolean isPercentTriggerEnabled() {
return percentEnabled && percentTrigger > 0;
}
/**
* Default config: 5 minutes, 10% trigger disabled.
*/
public static ResetConfig defaults() {
return new ResetConfig(
DEFAULT_INTERVAL,
DEFAULT_PERCENT_TRIGGER,
false,
List.of(),
""
);
}
/**
* Builder for fluent construction.
*/
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Duration interval = DEFAULT_INTERVAL;
private double percentTrigger = DEFAULT_PERCENT_TRIGGER;
private boolean percentEnabled = false;
private List<String> commands = List.of();
private String broadcastMessage = "";
public Builder interval(Duration interval) {
this.interval = interval;
return this;
}
public Builder intervalTicks(long ticks) {
this.interval = Duration.ofSeconds(ticks / 20);
return this;
}
public Builder interval(String humanReadable) {
this.interval = parseDuration(humanReadable);
return this;
}
public Builder percentTrigger(double percent) {
this.percentTrigger = percent;
return this;
}
public Builder percentEnabled(boolean enabled) {
this.percentEnabled = enabled;
return this;
}
public Builder commands(List<String> commands) {
this.commands = commands;
return this;
}
public Builder broadcastMessage(String message) {
this.broadcastMessage = message;
return this;
}
public ResetConfig build() {
return new ResetConfig(interval, percentTrigger, percentEnabled, commands, broadcastMessage);
}
private static Duration parseDuration(String s) {
s = s.trim().toLowerCase();
try {
if (s.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(s.substring(0, s.length() - 1)));
} else if (s.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(s.substring(0, s.length() - 1)));
} else if (s.endsWith("h")) {
return Duration.ofHours(Long.parseLong(s.substring(0, s.length() - 1)));
} else if (s.endsWith("d")) {
return Duration.ofDays(Long.parseLong(s.substring(0, s.length() - 1)));
} else {
// Assume seconds if no suffix
return Duration.ofSeconds(Long.parseLong(s));
}
} catch (NumberFormatException e) {
return DEFAULT_INTERVAL;
}
}
}
}

View file

@ -0,0 +1,141 @@
package dev.loki.lomines.data.config.reward;
import dev.loki.lomines.data.config.block.BlockKey;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Configuration for mine rewards - drops when breaking specific blocks.
*/
public record RewardConfig(List<RewardEntry> entries) {
public RewardConfig {
entries = entries != null ? List.copyOf(entries) : List.of();
}
/**
* Returns rewards applicable for the given block.
*/
public List<RewardEntry> forBlock(BlockKey block) {
return entries.stream()
.filter(e -> e.blocks().contains(block))
.toList();
}
/**
* Whether any rewards are configured.
*/
public boolean hasRewards() {
return !entries.isEmpty();
}
/**
* Empty reward config.
*/
public static RewardConfig empty() {
return new RewardConfig(List.of());
}
/**
* Single entry reward config builder.
*/
public static Builder builder() {
return new Builder();
}
// --- Reward Entry ---
public record RewardEntry(
List<BlockKey> blocks, // Which blocks trigger this reward
double chance, // 0.0-100.0 chance percentage
List<ItemReward> items, // Item drops
List<String> commands, // Commands to execute (%player% placeholder)
boolean preventVanillaDrops // Cancel vanilla drops
) {
public RewardEntry {
blocks = blocks != null ? List.copyOf(blocks) : List.of();
items = items != null ? List.copyOf(items) : List.of();
commands = commands != null ? List.copyOf(commands) : List.of();
if (chance < 0 || chance > 100) {
throw new IllegalArgumentException("Chance must be between 0 and 100: " + chance);
}
}
/**
* Check if this reward applies to the given block.
*/
public boolean appliesTo(BlockKey block) {
return blocks.contains(block);
}
/**
* Roll for this reward (0-100 random check).
*/
public boolean roll(java.util.Random random) {
return random.nextDouble() * 100.0 < chance;
}
public record ItemReward(
Material material,
int amount,
String displayName, // MiniMessage format
List<String> lore // MiniMessage format
) {
public ItemReward {
Objects.requireNonNull(material, "Material cannot be null");
amount = Math.max(1, Math.min(amount, 64));
lore = lore != null ? List.copyOf(lore) : List.of();
}
/**
* Build ItemStack from this reward.
*/
public ItemStack toItemStack() {
ItemStack item = new ItemStack(material, amount);
if (displayName != null || !lore.isEmpty()) {
item.editMeta(meta -> {
if (displayName != null) {
meta.displayName(net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(displayName));
}
if (!lore.isEmpty()) {
var mm = net.kyori.adventure.text.minimessage.MiniMessage.miniMessage();
meta.lore(lore.stream().map(mm::deserialize).toList());
}
});
}
return item;
}
}
}
// --- Builder ---
public static class Builder {
private final java.util.List<RewardEntry> entries = new java.util.ArrayList<>();
public Builder add(RewardEntry entry) {
entries.add(entry);
return this;
}
public Builder add(double chance, List<BlockKey> blocks, List<RewardEntry.ItemReward> items) {
entries.add(new RewardEntry(blocks, chance, items, List.of(), false));
return this;
}
public Builder add(double chance, BlockKey block, RewardEntry.ItemReward item) {
entries.add(new RewardEntry(List.of(block), chance, List.of(item), List.of(), false));
return this;
}
public RewardConfig build() {
return new RewardConfig(entries);
}
}
}

View file

@ -0,0 +1,46 @@
package dev.loki.lomines.data.config.teleport;
import org.bukkit.Location;
import java.util.Objects;
import java.util.Optional;
/**
* Configuration for mine teleportation on reset.
*/
public record TeleportConfig(
boolean enabled,
Location location
) {
public TeleportConfig {
if (!enabled) {
location = null;
} else if (location != null) {
// Defensive copy
location = location.clone();
Objects.requireNonNull(location.getWorld(), "Teleport world cannot be null");
}
}
/**
* Returns the teleport location if enabled.
*/
public Optional<Location> getLocation() {
return Optional.ofNullable(enabled ? location : null);
}
/**
* Disabled teleport config.
*/
public static TeleportConfig disabled() {
return new TeleportConfig(false, null);
}
/**
* Creates enabled config with location.
*/
public static TeleportConfig at(Location location) {
return new TeleportConfig(true, location);
}
}

View file

@ -0,0 +1,82 @@
package dev.loki.lomines.data.config.ui;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.Component;
import java.util.Objects;
/**
* Configuration for UI elements (action bar, messages).
*/
public record UIConfig(
boolean actionBarEnabled,
String actionBarFormat,
double actionBarRange,
String timerFormat
) {
public static final String DEFAULT_ACTIONBAR_FORMAT = "<green>{mine}</green> <gray>{percent}%</gray> <dark_gray>({time})";
public static final String DEFAULT_TIMER_FORMAT = "mm:ss";
public UIConfig {
actionBarFormat = actionBarFormat != null && !actionBarFormat.isBlank()
? actionBarFormat
: DEFAULT_ACTIONBAR_FORMAT;
timerFormat = timerFormat != null && !timerFormat.isBlank()
? timerFormat
: DEFAULT_TIMER_FORMAT;
actionBarRange = Math.max(1, actionBarRange);
}
/**
* Parses the action bar format with placeholders.
* Placeholders: {mine}, {percent}, {time}, {blocks}, {total}
*/
public Component formatActionBar(String mineName, double percent, String timeStr, int blocks, int total) {
String parsed = actionBarFormat
.replace("{mine}", mineName)
.replace("{percent}", String.format("%.1f", percent))
.replace("{time}", timeStr)
.replace("{blocks}", String.valueOf(blocks))
.replace("{total}", String.valueOf(total));
return MiniMessage.miniMessage().deserialize(parsed);
}
/**
* Formats a duration according to timerFormat.
* Supports: mm:ss, m:ss, HH:mm:ss, H:mm:ss
*/
public String formatTimer(long seconds) {
long hours = seconds / 3600;
long mins = (seconds % 3600) / 60;
long secs = seconds % 60;
return switch (timerFormat.toLowerCase()) {
case "hh:mm:ss", "h:mm:ss" -> String.format("%d:%02d:%02d", hours, mins, secs);
case "mm:ss", "m:ss" -> String.format("%02d:%02d", mins, secs);
default -> String.format("%02d:%02d", mins, secs);
};
}
/**
* Range squared for efficient distance checks.
*/
public double actionBarRangeSquared() {
return actionBarRange * actionBarRange;
}
/**
* Default configuration.
*/
public static UIConfig defaults() {
return new UIConfig(true, DEFAULT_ACTIONBAR_FORMAT, 50.0, DEFAULT_TIMER_FORMAT);
}
/**
* Disabled configuration (no UI).
*/
public static UIConfig disabled() {
return new UIConfig(false, "", 0, DEFAULT_TIMER_FORMAT);
}
}

View file

@ -0,0 +1,64 @@
# LoMines Default Configuration
# This file defines defaults for all mines.
# Individual mines can override any of these values.
# Reset behavior
reset:
# Interval between automatic resets
# Format: "5m" (minutes), "30s" (seconds), "1h" (hours), "1d" (days)
interval: "5m"
# Trigger reset when X% of blocks are mined
percent-trigger: 10.0
percent-enabled: false
# Commands to run on reset (%mine%, %player%, %blocks% placeholders)
commands: []
# - "broadcast Mine %mine% has been reset!"
# - "eco give %player% 10"
# Broadcast message on reset (empty = no broadcast)
broadcast: ""
# Block filling behavior
blocks:
# Fill mode: CUBOID (fill entire region) or MASK (fill marked positions only)
fill-mode: CUBOID
# For MASK mode: which block marks fill positions
mask:
marker: "pink_concrete"
positions: []
# UI settings
ui:
actionbar:
enabled: true
# MiniMessage format. Placeholders: {mine}, {percent}, {time}, {blocks}, {total}
format: "<green>{mine}</green> <gray>{percent}%</gray> <dark_gray>({time})"
range: 50.0
# Timer format for {time} placeholder
# mm:ss = 05:30, HH:mm:ss = 00:05:30
timer-format: "mm:ss"
# Rewards for mining specific blocks
rewards: []
# Example:
# - chance: 5.0
# blocks:
# - "diamond_ore"
# items:
# - type: "diamond"
# amount: 1
# name: "<yellow>Lucky Diamond"
# lore:
# - "<gray>Mined from the depths"
# commands:
# - "eco give %player% 100"
# prevent-drops: true
# Teleport players on reset
teleport:
enabled: false
location: ""

View file

@ -0,0 +1,191 @@
package dev.loki.lomines.data.config;
import dev.loki.lomines.data.config.block.BlockConfig;
import dev.loki.lomines.data.config.block.BlockKey;
import dev.loki.lomines.data.config.block.FillMode;
import dev.loki.lomines.data.config.region.RegionConfig;
import dev.loki.lomines.data.config.reset.ResetConfig;
import dev.loki.lomines.data.config.reward.RewardConfig;
import dev.loki.lomines.data.config.teleport.TeleportConfig;
import dev.loki.lomines.data.config.ui.UIConfig;
import dev.loki.lomines.util.location.Cuboid;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class MineConfigTest {
@Test
void testBuilder() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("world");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
MineConfig config = MineConfig.builder("testmine")
.region(region)
.blocks(blocks)
.reset(ResetConfig.defaults())
.rewards(RewardConfig.empty())
.teleport(TeleportConfig.disabled())
.ui(UIConfig.defaults())
.build();
assertEquals("testmine", config.name());
assertEquals(1, config.region().regionCount());
assertEquals(1, config.blocks().blockCount());
}
}
@Test
void testDefaultsFactory() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("world");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
MineConfig config = MineConfig.defaults("mymine", region, blocks);
assertEquals("mymine", config.name());
assertNotNull(config.reset());
assertNotNull(config.rewards());
assertNotNull(config.teleport());
assertNotNull(config.ui());
}
}
@Test
void testNameNormalized() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("world");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
MineConfig config = MineConfig.builder("TestMine_123 ")
.region(region)
.blocks(blocks)
.build();
assertEquals("testmine_123", config.name());
}
}
@Test
void testNullNameThrows() {
assertThrows(NullPointerException.class, () ->
MineConfig.builder(null));
}
@Test
void testBlankNameThrows() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("world");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
assertThrows(IllegalArgumentException.class, () ->
MineConfig.builder(" ")
.region(region)
.blocks(blocks)
.build());
}
}
@Test
void testNullRegionThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
assertThrows(NullPointerException.class, () ->
MineConfig.builder("test")
.region(null)
.blocks(blocks)
.build());
}
@Test
void testNullBlocksThrows() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("world");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
assertThrows(NullPointerException.class, () ->
MineConfig.builder("test")
.region(region)
.blocks(null)
.build());
}
}
@Test
void testWorldNameDelegation() {
try (MockedStatic<org.bukkit.Bukkit> bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class)) {
World world = mock(World.class);
when(world.getName()).thenReturn("nether");
bukkit.when(() -> org.bukkit.Bukkit.getWorld("nether")).thenReturn(world);
Location loc1 = new Location(world, 0, 64, 0);
Location loc2 = new Location(world, 10, 74, 10);
RegionConfig region = RegionConfig.fromSelections(List.of(loc1, loc2));
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig blocks = new BlockConfig(weights, FillMode.CUBOID, null);
MineConfig config = MineConfig.builder("test")
.region(region)
.blocks(blocks)
.build();
assertEquals("nether", config.worldName());
}
}
}

View file

@ -0,0 +1,144 @@
package dev.loki.lomines.data.config.block;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class BlockConfigTest {
@Test
void testValidConfig() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 50.0);
weights.put(new BlockKey.Vanilla(Material.DIRT), 30.0);
weights.put(new BlockKey.Vanilla(Material.COAL_ORE), 20.0);
BlockConfig config = new BlockConfig(weights, FillMode.CUBOID, null);
assertEquals(3, config.blockCount());
assertFalse(config.hasCustomBlocks());
assertEquals(FillMode.CUBOID, config.fillMode());
}
@Test
void testWeightsAreNormalized() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
weights.put(new BlockKey.Vanilla(Material.DIRT), 100.0);
BlockConfig config = new BlockConfig(weights, FillMode.CUBOID, null);
// Should be normalized to sum to 1.0
double stoneWeight = config.weightFor(new BlockKey.Vanilla(Material.STONE));
double dirtWeight = config.weightFor(new BlockKey.Vanilla(Material.DIRT));
assertEquals(0.5, stoneWeight, 0.001);
assertEquals(0.5, dirtWeight, 0.001);
}
@Test
void testEmptyConfigThrows() {
assertThrows(IllegalArgumentException.class, () ->
new BlockConfig(Map.of(), FillMode.CUBOID, null));
}
@Test
void testNegativeWeightThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), -10.0);
assertThrows(IllegalArgumentException.class, () ->
new BlockConfig(weights, FillMode.CUBOID, null));
}
@Test
void testZeroWeightThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 0.0);
assertThrows(IllegalArgumentException.class, () ->
new BlockConfig(weights, FillMode.CUBOID, null));
}
@Test
void testNullWeightThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), null);
assertThrows(IllegalArgumentException.class, () ->
new BlockConfig(weights, FillMode.CUBOID, null));
}
@Test
void testNullBlockKeyThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(null, 50.0);
assertThrows(NullPointerException.class, () ->
new BlockConfig(weights, FillMode.CUBOID, null));
}
@Test
void testCustomBlocksDetected() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 50.0);
weights.put(new BlockKey.Oraxen("ruby_ore"), 50.0);
BlockConfig config = new BlockConfig(weights, FillMode.CUBOID, null);
assertTrue(config.hasCustomBlocks());
}
@Test
void testVanillaOnlyFactory() {
Map<Material, Double> materials = new HashMap<>();
materials.put(Material.STONE, 60.0);
materials.put(Material.DIRT, 40.0);
BlockConfig config = BlockConfig.vanilla(materials);
assertEquals(2, config.blockCount());
assertFalse(config.hasCustomBlocks());
}
@Test
void testImmutableWeights() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockConfig config = new BlockConfig(weights, FillMode.CUBOID, null);
// Try to modify the returned map
Map<BlockKey, Double> returnedWeights = config.weights();
assertThrows(UnsupportedOperationException.class, () ->
returnedWeights.put(new BlockKey.Vanilla(Material.DIRT), 50.0));
}
@Test
void testMaskConfig() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
BlockKey marker = new BlockKey.Vanilla(Material.PINK_CONCRETE);
Map<String, Boolean> positions = new HashMap<>();
positions.put("world;10;64;10;0;0", true);
BlockConfig.MaskConfig mask = new BlockConfig.MaskConfig(marker, positions);
BlockConfig config = new BlockConfig(weights, FillMode.MASK, mask);
assertEquals(FillMode.MASK, config.fillMode());
assertNotNull(config.mask());
}
@Test
void testFillModeNullThrows() {
Map<BlockKey, Double> weights = new HashMap<>();
weights.put(new BlockKey.Vanilla(Material.STONE), 100.0);
assertThrows(NullPointerException.class, () ->
new BlockConfig(weights, null, null));
}
}

View file

@ -0,0 +1,112 @@
package dev.loki.lomines.data.config.block;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BlockKeyTest {
@Test
void testVanillaSerialization() {
BlockKey.Vanilla stone = new BlockKey.Vanilla(Material.STONE);
assertEquals("stone", stone.serialize());
BlockKey.Vanilla diamondOre = new BlockKey.Vanilla(Material.DIAMOND_ORE);
assertEquals("diamond_ore", diamondOre.serialize());
}
@Test
void testOraxenSerialization() {
BlockKey.Oraxen custom = new BlockKey.Oraxen("ruby_ore");
assertEquals("oraxen:ruby_ore", custom.serialize());
}
@Test
void testItemsAdderSerialization() {
BlockKey.ItemsAdder custom = new BlockKey.ItemsAdder("mythic_block");
assertEquals("itemsadder:mythic_block", custom.serialize());
}
@Test
void testDeserializeVanilla() {
BlockKey key = BlockKey.deserialize("stone");
assertInstanceOf(BlockKey.Vanilla.class, key);
assertEquals(Material.STONE, ((BlockKey.Vanilla) key).material());
}
@Test
void testDeserializeOraxen() {
BlockKey key = BlockKey.deserialize("oraxen:custom_block");
assertInstanceOf(BlockKey.Oraxen.class, key);
assertEquals("custom_block", ((BlockKey.Oraxen) key).id());
}
@Test
void testDeserializeItemsAdder() {
BlockKey key = BlockKey.deserialize("itemsadder:mythic_ore");
assertInstanceOf(BlockKey.ItemsAdder.class, key);
assertEquals("mythic_ore", ((BlockKey.ItemsAdder) key).id());
}
@Test
void testDeserializeNullThrows() {
assertThrows(IllegalArgumentException.class, () -> BlockKey.deserialize(null));
}
@Test
void testDeserializeEmptyThrows() {
assertThrows(IllegalArgumentException.class, () -> BlockKey.deserialize(""));
}
@Test
void testDeserializeBlankThrows() {
assertThrows(IllegalArgumentException.class, () -> BlockKey.deserialize(" "));
}
@Test
void testDeserializeUnknownVanillaThrows() {
assertThrows(IllegalArgumentException.class, () -> BlockKey.deserialize("not_a_real_block"));
}
@Test
void testVanillaNotBlockThrows() {
assertThrows(IllegalArgumentException.class, () -> new BlockKey.Vanilla(Material.DIAMOND));
}
@Test
void testOraxenNullIdThrows() {
assertThrows(NullPointerException.class, () -> new BlockKey.Oraxen(null));
}
@Test
void testOraxenBlankIdThrows() {
assertThrows(IllegalArgumentException.class, () -> new BlockKey.Oraxen(" "));
}
@Test
void testItemsAdderEmptyIdThrows() {
assertThrows(IllegalArgumentException.class, () -> new BlockKey.ItemsAdder(""));
}
@Test
void testCaseInsensitiveDeserialization() {
BlockKey key1 = BlockKey.deserialize("STONE");
BlockKey key2 = BlockKey.deserialize("stone");
BlockKey key3 = BlockKey.deserialize("StOnE");
assertEquals(key1.serialize(), key2.serialize());
assertEquals(key2.serialize(), key3.serialize());
}
@Test
void testEquality() {
BlockKey a = new BlockKey.Vanilla(Material.STONE);
BlockKey b = new BlockKey.Vanilla(Material.STONE);
BlockKey c = new BlockKey.Vanilla(Material.DIRT);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
}
}

View file

@ -0,0 +1,169 @@
package dev.loki.lomines.data.config.reset;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ResetConfigTest {
@Test
void testDefaultConfig() {
ResetConfig config = ResetConfig.defaults();
assertEquals(Duration.ofMinutes(5), config.interval());
assertEquals(10.0, config.percentTrigger());
assertFalse(config.percentEnabled());
assertTrue(config.commands().isEmpty());
assertEquals("", config.broadcastMessage());
}
@Test
void testBuilder() {
ResetConfig config = ResetConfig.builder()
.interval("10m")
.percentTrigger(25.0)
.percentEnabled(true)
.commands(List.of("broadcast Reset!", "eco give %player% 10"))
.broadcastMessage("Mine reset!")
.build();
assertEquals(Duration.ofMinutes(10), config.interval());
assertEquals(25.0, config.percentTrigger());
assertTrue(config.percentEnabled());
assertEquals(2, config.commands().size());
assertEquals("Mine reset!", config.broadcastMessage());
}
@Test
void testBuilderWithTicks() {
ResetConfig config = ResetConfig.builder()
.intervalTicks(1200) // 60 seconds
.build();
assertEquals(Duration.ofSeconds(60), config.interval());
assertEquals(1200, config.intervalTicks());
}
@Test
void testIntervalParsing() {
assertEquals(Duration.ofSeconds(30), ResetConfig.builder().interval("30s").build().interval());
assertEquals(Duration.ofMinutes(5), ResetConfig.builder().interval("5m").build().interval());
assertEquals(Duration.ofHours(2), ResetConfig.builder().interval("2h").build().interval());
assertEquals(Duration.ofDays(1), ResetConfig.builder().interval("1d").build().interval());
}
@Test
void testInvalidIntervalDefaultsToFiveMinutes() {
ResetConfig config = ResetConfig.builder()
.interval("invalid")
.build();
assertEquals(ResetConfig.DEFAULT_INTERVAL, config.interval());
}
@Test
void testNegativeIntervalDefaults() {
ResetConfig config = new ResetConfig(
Duration.ofSeconds(-10),
10.0,
false,
List.of(),
""
);
assertEquals(ResetConfig.DEFAULT_INTERVAL, config.interval());
}
@Test
void testZeroIntervalDefaults() {
ResetConfig config = new ResetConfig(
Duration.ZERO,
10.0,
false,
List.of(),
""
);
assertEquals(ResetConfig.DEFAULT_INTERVAL, config.interval());
}
@Test
void testTooShortIntervalClamped() {
ResetConfig config = ResetConfig.builder()
.interval("0.5s")
.build();
assertEquals(Duration.ofSeconds(1), config.interval());
}
@Test
void testTooLongIntervalClamped() {
ResetConfig config = ResetConfig.builder()
.interval("48h")
.build();
assertEquals(Duration.ofHours(24), config.interval());
}
@Test
void testInvalidPercentTriggerClamped() {
ResetConfig negative = new ResetConfig(
Duration.ofMinutes(5),
-10.0,
false,
List.of(),
""
);
assertEquals(ResetConfig.DEFAULT_PERCENT_TRIGGER, negative.percentTrigger());
ResetConfig over100 = new ResetConfig(
Duration.ofMinutes(5),
150.0,
false,
List.of(),
""
);
assertEquals(ResetConfig.DEFAULT_PERCENT_TRIGGER, over100.percentTrigger());
}
@Test
void testIntervalDisplay() {
assertEquals("30s", new ResetConfig(Duration.ofSeconds(30), 10.0, false, List.of(), "").intervalDisplay());
assertEquals("5m", new ResetConfig(Duration.ofMinutes(5), 10.0, false, List.of(), "").intervalDisplay());
assertEquals("2h", new ResetConfig(Duration.ofHours(2), 10.0, false, List.of(), "").intervalDisplay());
}
@Test
void testPercentTriggerEnabled() {
assertFalse(ResetConfig.defaults().isPercentTriggerEnabled());
assertFalse(new ResetConfig(Duration.ofMinutes(5), 10.0, false, List.of(), "").isPercentTriggerEnabled());
assertTrue(new ResetConfig(Duration.ofMinutes(5), 10.0, true, List.of(), "").isPercentTriggerEnabled());
assertFalse(new ResetConfig(Duration.ofMinutes(5), 0.0, true, List.of(), "").isPercentTriggerEnabled());
}
@Test
void testImmutableCommands() {
ResetConfig config = ResetConfig.builder()
.commands(List.of("cmd1"))
.build();
assertThrows(UnsupportedOperationException.class, () ->
config.commands().add("cmd2"));
}
@Test
void testNullCommandsDefaultsToEmpty() {
ResetConfig config = new ResetConfig(
Duration.ofMinutes(5),
10.0,
false,
null,
"test"
);
assertTrue(config.commands().isEmpty());
}
}

View file

@ -0,0 +1,112 @@
package dev.loki.lomines.data.config.ui;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UIConfigTest {
@Test
void testDefaults() {
UIConfig config = UIConfig.defaults();
assertTrue(config.actionBarEnabled());
assertEquals(UIConfig.DEFAULT_ACTIONBAR_FORMAT, config.actionBarFormat());
assertEquals(50.0, config.actionBarRange());
assertEquals(UIConfig.DEFAULT_TIMER_FORMAT, config.timerFormat());
}
@Test
void testDisabled() {
UIConfig config = UIConfig.disabled();
assertFalse(config.actionBarEnabled());
assertEquals("", config.actionBarFormat());
assertEquals(0, config.actionBarRange());
}
@Test
void testCustomConfig() {
UIConfig config = new UIConfig(
true,
"<red>{mine}</red>",
100.0,
"HH:mm:ss"
);
assertTrue(config.actionBarEnabled());
assertEquals("<red>{mine}</red>", config.actionBarFormat());
assertEquals(100.0, config.actionBarRange());
assertEquals("HH:mm:ss", config.timerFormat());
}
@Test
void testNullFormatDefaults() {
UIConfig config = new UIConfig(true, null, 50.0, null);
assertEquals(UIConfig.DEFAULT_ACTIONBAR_FORMAT, config.actionBarFormat());
assertEquals(UIConfig.DEFAULT_TIMER_FORMAT, config.timerFormat());
}
@Test
void testBlankFormatDefaults() {
UIConfig config = new UIConfig(true, " ", 50.0, " ");
assertEquals(UIConfig.DEFAULT_ACTIONBAR_FORMAT, config.actionBarFormat());
assertEquals(UIConfig.DEFAULT_TIMER_FORMAT, config.timerFormat());
}
@Test
void testNegativeRangeClamped() {
UIConfig config = new UIConfig(true, "test", -10.0, "mm:ss");
assertEquals(1.0, config.actionBarRange());
}
@Test
void testRangeSquared() {
UIConfig config = new UIConfig(true, "test", 50.0, "mm:ss");
assertEquals(2500.0, config.actionBarRangeSquared(), 0.001);
}
@Test
void testFormatActionBar() {
UIConfig config = UIConfig.defaults();
Component result = config.formatActionBar("TestMine", 50.5, "02:30", 500, 1000);
String serialized = MiniMessage.miniMessage().serialize(result);
assertTrue(serialized.contains("TestMine"));
assertTrue(serialized.contains("50.5"));
assertTrue(serialized.contains("02:30"));
}
@Test
void testFormatTimerMmSs() {
UIConfig config = new UIConfig(true, "", 0, "mm:ss");
assertEquals("00:30", config.formatTimer(30));
assertEquals("05:00", config.formatTimer(300));
assertEquals("59:59", config.formatTimer(3599));
}
@Test
void testFormatTimerHhMmSs() {
UIConfig config = new UIConfig(true, "", 0, "HH:mm:ss");
assertEquals("0:00:30", config.formatTimer(30));
assertEquals("0:05:00", config.formatTimer(300));
assertEquals("1:00:00", config.formatTimer(3600));
assertEquals("2:30:45", config.formatTimer(9045));
}
@Test
void testFormatTimerDefault() {
UIConfig config = UIConfig.defaults();
assertEquals("02:30", config.formatTimer(150));
assertEquals("10:00", config.formatTimer(600));
}
}