From f1060f34d4428efcf0308f895a5b411dd9b8ffdc Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 31 May 2026 21:57:10 +0200 Subject: [PATCH] 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 --- .../lomines/data/config/ConfigLoader.java | 476 +++++++++++++++--- .../lomines/data/config/ConfigSerializer.java | 142 ------ .../lomines/data/config/ConfigValidator.java | 138 ----- .../loki/lomines/data/config/FillMode.java | 13 - .../loki/lomines/data/config/MineConfig.java | 251 ++++----- .../data/config/MineConfigBuilder.java | 129 ----- .../data/config/block/BlockConfig.java | 98 ++++ .../lomines/data/config/block/BlockKey.java | 89 ++++ .../lomines/data/config/block/FillMode.java | 17 + .../config/parser/ConfigParseException.java | 16 - .../data/config/parser/ConfigParser.java | 221 -------- .../data/config/region/RegionConfig.java | 99 ++++ .../data/config/reset/ResetConfig.java | 158 ++++++ .../data/config/reward/RewardConfig.java | 141 ++++++ .../data/config/teleport/TeleportConfig.java | 46 ++ .../loki/lomines/data/config/ui/UIConfig.java | 82 +++ src/main/resources/defaults.yml | 64 +++ .../lomines/data/config/MineConfigTest.java | 191 +++++++ .../data/config/block/BlockConfigTest.java | 144 ++++++ .../data/config/block/BlockKeyTest.java | 112 +++++ .../data/config/reset/ResetConfigTest.java | 169 +++++++ .../lomines/data/config/ui/UIConfigTest.java | 112 +++++ 22 files changed, 2012 insertions(+), 896 deletions(-) delete mode 100644 src/main/java/dev/loki/lomines/data/config/ConfigSerializer.java delete mode 100644 src/main/java/dev/loki/lomines/data/config/ConfigValidator.java delete mode 100644 src/main/java/dev/loki/lomines/data/config/FillMode.java delete mode 100644 src/main/java/dev/loki/lomines/data/config/MineConfigBuilder.java create mode 100644 src/main/java/dev/loki/lomines/data/config/block/BlockConfig.java create mode 100644 src/main/java/dev/loki/lomines/data/config/block/BlockKey.java create mode 100644 src/main/java/dev/loki/lomines/data/config/block/FillMode.java delete mode 100644 src/main/java/dev/loki/lomines/data/config/parser/ConfigParseException.java delete mode 100644 src/main/java/dev/loki/lomines/data/config/parser/ConfigParser.java create mode 100644 src/main/java/dev/loki/lomines/data/config/region/RegionConfig.java create mode 100644 src/main/java/dev/loki/lomines/data/config/reset/ResetConfig.java create mode 100644 src/main/java/dev/loki/lomines/data/config/reward/RewardConfig.java create mode 100644 src/main/java/dev/loki/lomines/data/config/teleport/TeleportConfig.java create mode 100644 src/main/java/dev/loki/lomines/data/config/ui/UIConfig.java create mode 100644 src/main/resources/defaults.yml create mode 100644 src/test/java/dev/loki/lomines/data/config/MineConfigTest.java create mode 100644 src/test/java/dev/loki/lomines/data/config/block/BlockConfigTest.java create mode 100644 src/test/java/dev/loki/lomines/data/config/block/BlockKeyTest.java create mode 100644 src/test/java/dev/loki/lomines/data/config/reset/ResetConfigTest.java create mode 100644 src/test/java/dev/loki/lomines/data/config/ui/UIConfigTest.java diff --git a/src/main/java/dev/loki/lomines/data/config/ConfigLoader.java b/src/main/java/dev/loki/lomines/data/config/ConfigLoader.java index f5cb219..7abaeb7 100644 --- a/src/main/java/dev/loki/lomines/data/config/ConfigLoader.java +++ b/src/main/java/dev/loki/lomines/data/config/ConfigLoader.java @@ -1,111 +1,427 @@ 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 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.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.Material; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.util.*; /** - * 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. + * Clean config loader for section-based configuration. + * Supports YAML inheritance from defaults. */ public final class ConfigLoader { - private final ConfigParser parser; - private final ConfigValidator validator; - private final ConfigSerializer serializer; + private final Path dataFolder; + private YamlConfiguration defaults; - public ConfigLoader() { - RewardParser rewardParser = new RewardParser(); - this.parser = new ConfigParser(rewardParser); - this.validator = new ConfigValidator(); - this.serializer = new ConfigSerializer(); + public ConfigLoader(Path dataFolder) { + this.dataFolder = dataFolder; + loadDefaults(); } /** - * 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 + * Load or create defaults.yml. */ - public MineConfig load(Path configFile) throws IOException, ConfigParseException { - if (configFile == null) { - throw new ConfigParseException("Config file path cannot be null"); + private void loadDefaults() { + Path defaultsPath = dataFolder.resolve("mines").resolve("_defaults.yml"); + 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", "{mine} {percent}% ({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()) { - throw new IOException("Config file does not exist: " + configFile); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(configPath.toFile()); + + // 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 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 - List selections = parser.parseSelections(yaml); - Map blocks = parser.parseBlocks(yaml); - FillMode fillMode = parser.parseFillMode(yaml); - String maskMarker = parser.parseMaskMarker(yaml); - List maskPositions = parser.parseMaskPositions(yaml); - List rewards = parser.parseRewards(yaml); - Location teleportLocation = parser.parseTeleportLocation(yaml); + if (selections.size() < 2) { + throw new IllegalArgumentException("Mine must have at least 2 selection points (1 region)"); + } - // Validate all data - validator.validateSelections(selections); - validator.validateBlocks(blocks); - validator.validateMaskMarker(maskMarker); - validator.validateRewards(rewards); + return RegionConfig.fromSelections(selections); + } - // 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", "")) + private BlockConfig parseBlocks(YamlConfiguration yaml) { + ConfigurationSection contents = yaml.getConfigurationSection("blocks.contents"); + if (contents == null) { + contents = yaml.getConfigurationSection("contents"); // Legacy fallback + } + + if (contents == null) { + throw new IllegalArgumentException("Missing 'blocks.contents' section"); + } + + Map weights = new HashMap<>(); + for (String key : contents.getKeys(false)) { + double weight = contents.getDouble(key); + if (weight <= 0) continue; + weights.put(BlockKey.deserialize(key), weight); + } + + FillMode fillMode = FillMode.valueOf( + 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 positions = new HashMap<>(); + List 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(); } - /** - * 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"); + private RewardConfig parseRewards(YamlConfiguration yaml) { + List entries = new ArrayList<>(); + List> rewardList = yaml.getMapList("rewards"); + + if (rewardList.isEmpty()) { + rewardList = yaml.getMapList("random-rewards"); // Legacy fallback } - YamlConfiguration yaml = new YamlConfiguration(); - serializer.serialize(config, yaml); - yaml.save(configFile.toFile()); + for (Map map : rewardList) { + entries.add(parseRewardEntry(map)); + } + + return new RewardConfig(entries); + } + + private RewardEntry parseRewardEntry(Map map) { + double chance = ((Number) map.getOrDefault("chance", 0)).doubleValue(); + + List blocks = new ArrayList<>(); + List blockList = (List) map.get("blocks"); + if (blockList != null) { + for (Object b : blockList) { + blocks.add(BlockKey.deserialize(b.toString())); + } + } + + List items = new ArrayList<>(); + List itemList = (List) map.get("items"); + if (itemList != null) { + for (Object i : itemList) { + @SuppressWarnings("unchecked") + Map itemMap = (Map) i; + items.add(parseItemReward(itemMap)); + } + } + + List commands = (List) 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 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 lore = (List) 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> list = new ArrayList<>(); + for (RewardEntry entry : rewards.entries()) { + Map map = new LinkedHashMap<>(); + map.put("chance", entry.chance()); + map.put("blocks", entry.blocks().stream().map(BlockKey::serialize).toList()); + + List> items = new ArrayList<>(); + for (ItemReward item : entry.items()) { + Map 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); + } } } diff --git a/src/main/java/dev/loki/lomines/data/config/ConfigSerializer.java b/src/main/java/dev/loki/lomines/data/config/ConfigSerializer.java deleted file mode 100644 index 4b1d7b0..0000000 --- a/src/main/java/dev/loki/lomines/data/config/ConfigSerializer.java +++ /dev/null @@ -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 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 blocks) { - for (Map.Entry 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 maskPositions) { - if (maskPositions == null || maskPositions.isEmpty()) { - yaml.set("mask.positions", new ArrayList()); - return; - } - - List lines = new ArrayList<>(); - for (Location loc : maskPositions) { - lines.add(LocationParser.format(loc)); - } - yaml.set("mask.positions", lines); - } - - /** - * Saves rewards to the YAML configuration. - * - * @param yaml the YAML configuration - * @param rewards the list of rewards - */ - private void saveRewards(YamlConfiguration yaml, List rewards) { - if (rewards.isEmpty()) { - return; - } - - List> rewardsList = new ArrayList<>(); - - for (Reward reward : rewards) { - Map rewardMap = new HashMap<>(); - - rewardMap.put("chance", reward.getChance()); - rewardMap.put("prevent-drops", reward.isPreventDrops()); - - List blockNames = new ArrayList<>(); - for (Material material : reward.getMaterials()) { - blockNames.add(material.name().toLowerCase()); - } - rewardMap.put("blocks", blockNames); - - if (!reward.getItems().isEmpty()) { - List> itemsList = new ArrayList<>(); - for (ItemStack item : reward.getItems()) { - Map itemMap = new HashMap<>(); - itemMap.put("type", item.getType().name().toLowerCase()); - itemMap.put("amount", item.getAmount()); - itemsList.add(itemMap); - } - rewardMap.put("items", itemsList); - } - - if (!reward.getCommands().isEmpty()) { - rewardMap.put("commands", new ArrayList<>(reward.getCommands())); - } - - rewardsList.add(rewardMap); - } - - yaml.set("random-rewards", rewardsList); - } -} diff --git a/src/main/java/dev/loki/lomines/data/config/ConfigValidator.java b/src/main/java/dev/loki/lomines/data/config/ConfigValidator.java deleted file mode 100644 index 84a097e..0000000 --- a/src/main/java/dev/loki/lomines/data/config/ConfigValidator.java +++ /dev/null @@ -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 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 blocks) throws ConfigParseException { - if (blocks.isEmpty()) { - throw new ConfigParseException( - "No blocks defined in 'contents' section. At least one block is required" - ); - } - - for (Map.Entry 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 rewards) throws ConfigParseException { - for (Reward reward : rewards) { - validateReward(reward); - } - } -} diff --git a/src/main/java/dev/loki/lomines/data/config/FillMode.java b/src/main/java/dev/loki/lomines/data/config/FillMode.java deleted file mode 100644 index 1a3b2db..0000000 --- a/src/main/java/dev/loki/lomines/data/config/FillMode.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.loki.lomines.data.config; - -/** - * How mine contents are placed on reset. - *
    - *
  • {@link #CUBOID} — fill entire selection box (classic)
  • - *
  • {@link #MASK} — only at stored block positions (painted with a marker block, then scanned)
  • - *
- */ -public enum FillMode { - CUBOID, - MASK -} diff --git a/src/main/java/dev/loki/lomines/data/config/MineConfig.java b/src/main/java/dev/loki/lomines/data/config/MineConfig.java index 9751a94..fbfa0fa 100644 --- a/src/main/java/dev/loki/lomines/data/config/MineConfig.java +++ b/src/main/java/dev/loki/lomines/data/config/MineConfig.java @@ -1,189 +1,126 @@ package dev.loki.lomines.data.config; -import dev.loki.lomines.data.reward.Reward; -import org.bukkit.Location; +import dev.loki.lomines.data.config.block.BlockConfig; +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; /** - * Immutable configuration data model for a mine. - * All fields are final to ensure immutability. - * Use the Builder pattern to create instances. + * Clean, type-safe, section-based mine configuration. + * Proper separation of concerns: each section handles one aspect. */ -public final class MineConfig { +public record MineConfig( + String name, + RegionConfig region, + BlockConfig blocks, + ResetConfig reset, + RewardConfig rewards, + TeleportConfig teleport, + UIConfig ui +) { - // Regions - private final List selections; + public MineConfig { + Objects.requireNonNull(name, "Mine name cannot be null"); + if (name.isBlank()) { + throw new IllegalArgumentException("Mine name cannot be blank"); + } + name = name.toLowerCase().trim(); - // Blocks - private final Map blocks; - - /** - * How blocks are placed on reset. - */ - private final FillMode fillMode; - /** - * Vanilla material name for {@link FillMode#MASK} scan (e.g. pink_concrete). - */ - private final String maskMarkerMaterial; - /** - * Block centers for mask mode; ignored when {@link #fillMode} is {@link FillMode#CUBOID}. - */ - private final List maskPositions; - - // Rewards - private final List rewards; - - // Reset - private final int resetTicks; - private final double resetPercent; - private final boolean resetOnPercentEnabled; - private final List resetCommands; - private final String broadcastReset; - - // Teleportation - private final boolean teleportOnReset; - private final Location teleportLocation; - - // Action Bar - private final boolean actionBarEnabled; - private final String actionBarMessage; - private final double actionBarRange; - - // Timer - private final String timerFormat; - - MineConfig(List selections, Map blocks, FillMode fillMode, - String maskMarkerMaterial, List maskPositions, List rewards, - int resetTicks, double resetPercent, boolean resetOnPercentEnabled, - List 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 getSelections() { - return selections; - } - - public Map getBlocks() { - return blocks; - } - - public FillMode getFillMode() { - return fillMode; + Objects.requireNonNull(region, "Region config cannot be null"); + Objects.requireNonNull(blocks, "Block config cannot be null"); + Objects.requireNonNull(reset, "Reset config cannot be null"); + Objects.requireNonNull(rewards, "Reward config cannot be null"); + Objects.requireNonNull(teleport, "Teleport config cannot be null"); + Objects.requireNonNull(ui, "UI config cannot be null"); } /** - * Material name for scanning marker blocks (vanilla), e.g. {@code pink_concrete}. + * Total volume of the mine (for progress calculation). */ - public String getMaskMarkerMaterial() { - return maskMarkerMaterial; + public int volume() { + return region.totalVolume(); } - public List getMaskPositions() { - return maskPositions; + /** + * World name where the mine is located. + */ + public String worldName() { + return region.worldName(); } - public List 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() { - return resetPercent; - } + // --- Builder --- - public boolean isResetOnPercentEnabled() { - return resetOnPercentEnabled; - } + public static class Builder { + 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 getResetCommands() { - return resetCommands; - } + private Builder(String name) { + this.name = name; + } - public String getBroadcastReset() { - return broadcastReset; - } + public Builder region(RegionConfig region) { + this.region = region; + return this; + } - public boolean isTeleportOnReset() { - return teleportOnReset; - } + public Builder blocks(BlockConfig blocks) { + this.blocks = blocks; + return this; + } - public Location getTeleportLocation() { - return teleportLocation; - } + public Builder reset(ResetConfig reset) { + this.reset = reset; + return this; + } - public boolean isActionBarEnabled() { - return actionBarEnabled; - } + public Builder rewards(RewardConfig rewards) { + this.rewards = rewards; + return this; + } - public String getActionBarMessage() { - return actionBarMessage; - } + public Builder teleport(TeleportConfig teleport) { + this.teleport = teleport; + return this; + } - public double getActionBarRange() { - return actionBarRange; - } + public Builder ui(UIConfig ui) { + this.ui = ui; + return this; + } - public String getTimerFormat() { - return timerFormat; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - MineConfig that = (MineConfig) o; - return resetTicks == that.resetTicks && - Double.compare(that.resetPercent, resetPercent) == 0 && - resetOnPercentEnabled == that.resetOnPercentEnabled && - teleportOnReset == that.teleportOnReset && - actionBarEnabled == that.actionBarEnabled && - Double.compare(that.actionBarRange, actionBarRange) == 0 && - Objects.equals(selections, that.selections) && - Objects.equals(blocks, that.blocks) && - fillMode == that.fillMode && - Objects.equals(maskMarkerMaterial, that.maskMarkerMaterial) && - Objects.equals(maskPositions, that.maskPositions) && - Objects.equals(rewards, that.rewards) && - Objects.equals(resetCommands, that.resetCommands) && - Objects.equals(broadcastReset, that.broadcastReset) && - Objects.equals(teleportLocation, that.teleportLocation) && - Objects.equals(actionBarMessage, that.actionBarMessage) && - Objects.equals(timerFormat, that.timerFormat); - } - - @Override - public int hashCode() { - return Objects.hash(selections, blocks, fillMode, maskMarkerMaterial, maskPositions, rewards, resetTicks, resetPercent, - resetOnPercentEnabled, resetCommands, broadcastReset, teleportOnReset, - teleportLocation, actionBarEnabled, actionBarMessage, actionBarRange, timerFormat); + public MineConfig build() { + return new MineConfig(name, region, blocks, reset, rewards, teleport, ui); + } } } diff --git a/src/main/java/dev/loki/lomines/data/config/MineConfigBuilder.java b/src/main/java/dev/loki/lomines/data/config/MineConfigBuilder.java deleted file mode 100644 index 1db21d1..0000000 --- a/src/main/java/dev/loki/lomines/data/config/MineConfigBuilder.java +++ /dev/null @@ -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 selections = new ArrayList<>(); - private Map blocks = new HashMap<>(); - private FillMode fillMode = FillMode.CUBOID; - private String maskMarkerMaterial = "pink_concrete"; - private List maskPositions = new ArrayList<>(); - private List rewards = new ArrayList<>(); - private int resetTicks = 6000; - private double resetPercent = 10.0; - private boolean resetOnPercentEnabled = false; - private List resetCommands = new ArrayList<>(); - private String broadcastReset = ""; - private boolean teleportOnReset = false; - private Location teleportLocation = null; - private boolean actionBarEnabled = true; - private String actionBarMessage = ""; - private double actionBarRange = 50.0; - private String timerFormat = "mm:ss"; - - MineConfigBuilder() { - } - - public MineConfigBuilder selections(List selections) { - this.selections = new ArrayList<>(selections); - return this; - } - - public MineConfigBuilder blocks(Map 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 maskPositions) { - this.maskPositions = maskPositions != null ? new ArrayList<>(maskPositions) : new ArrayList<>(); - return this; - } - - public MineConfigBuilder rewards(List 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 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 - ); - } -} diff --git a/src/main/java/dev/loki/lomines/data/config/block/BlockConfig.java b/src/main/java/dev/loki/lomines/data/config/block/BlockConfig.java new file mode 100644 index 0000000..fb3cbe6 --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/block/BlockConfig.java @@ -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 weights, + FillMode fillMode, + MaskConfig mask +) { + + public BlockConfig { + // Defensive copy and validation + Map 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 materials) { + Map 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 positions // serialized location -> enabled + ) { + public MaskConfig { + positions = positions != null + ? Collections.unmodifiableMap(new HashMap<>(positions)) + : Map.of(); + } + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/block/BlockKey.java b/src/main/java/dev/loki/lomines/data/config/block/BlockKey.java new file mode 100644 index 0000000..eecce4e --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/block/BlockKey.java @@ -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; + } + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/block/FillMode.java b/src/main/java/dev/loki/lomines/data/config/block/FillMode.java new file mode 100644 index 0000000..0b0282f --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/block/FillMode.java @@ -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 +} diff --git a/src/main/java/dev/loki/lomines/data/config/parser/ConfigParseException.java b/src/main/java/dev/loki/lomines/data/config/parser/ConfigParseException.java deleted file mode 100644 index 76596c9..0000000 --- a/src/main/java/dev/loki/lomines/data/config/parser/ConfigParseException.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/dev/loki/lomines/data/config/parser/ConfigParser.java b/src/main/java/dev/loki/lomines/data/config/parser/ConfigParser.java deleted file mode 100644 index bd4d921..0000000 --- a/src/main/java/dev/loki/lomines/data/config/parser/ConfigParser.java +++ /dev/null @@ -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 parseSelections(YamlConfiguration yaml) throws ConfigParseException { - List selections = new ArrayList<>(); - - for (int i = 1; i <= 10; i++) { - String key = "selection." + i; - if (yaml.contains(key)) { - String locationString = yaml.getString(key); - if (locationString == null || locationString.trim().isEmpty()) { - throw new ConfigParseException( - "Selection coordinate at '" + key + "' is empty" - ); - } - - try { - Location location = LocationParser.parse(locationString); - selections.add(location); - } catch (ConfigParseException e) { - throw new ConfigParseException( - "Invalid selection coordinate at '" + key + "': " + e.getMessage(), - e - ); - } - } - } - - 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 parseBlocks(YamlConfiguration yaml) throws ConfigParseException { - ConfigurationSection contentsSection = yaml.getConfigurationSection("contents"); - - if (contentsSection == null) { - throw new ConfigParseException( - "Missing required 'contents' section in configuration" - ); - } - - Map blocks = new HashMap<>(); - - for (String key : contentsSection.getKeys(false)) { - Object value = contentsSection.get(key); - - double weight = 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 parseMaskPositions(YamlConfiguration yaml) throws ConfigParseException { - List rawList = yaml.getStringList("mask.positions"); - if (rawList == null || rawList.isEmpty()) { - return List.of(); - } - - List out = new ArrayList<>(); - for (int i = 0; i < rawList.size(); i++) { - String line = rawList.get(i); - if (line == null || line.isBlank()) { - continue; - } - try { - out.add(LocationParser.parse(line.trim())); - } catch (ConfigParseException e) { - throw new ConfigParseException( - "Invalid mask.positions entry at index " + i + ": " + e.getMessage(), - e - ); - } - } - return out; - } - - /** - * Parses rewards from the YAML configuration. - * 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 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(); - } -} diff --git a/src/main/java/dev/loki/lomines/data/config/region/RegionConfig.java b/src/main/java/dev/loki/lomines/data/config/region/RegionConfig.java new file mode 100644 index 0000000..25dd60c --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/region/RegionConfig.java @@ -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 regions) { + + public RegionConfig { + if (regions == null || regions.isEmpty()) { + throw new IllegalArgumentException("Mine must have at least one region"); + } + + // Defensive copy and validate + List 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 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(); + 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(); + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/reset/ResetConfig.java b/src/main/java/dev/loki/lomines/data/config/reset/ResetConfig.java new file mode 100644 index 0000000..c97ee17 --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/reset/ResetConfig.java @@ -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 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 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 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; + } + } + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/reward/RewardConfig.java b/src/main/java/dev/loki/lomines/data/config/reward/RewardConfig.java new file mode 100644 index 0000000..9d8c2b7 --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/reward/RewardConfig.java @@ -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 entries) { + + public RewardConfig { + entries = entries != null ? List.copyOf(entries) : List.of(); + } + + /** + * Returns rewards applicable for the given block. + */ + public List 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 blocks, // Which blocks trigger this reward + double chance, // 0.0-100.0 chance percentage + List items, // Item drops + List 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 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 entries = new java.util.ArrayList<>(); + + public Builder add(RewardEntry entry) { + entries.add(entry); + return this; + } + + public Builder add(double chance, List blocks, List 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); + } + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/teleport/TeleportConfig.java b/src/main/java/dev/loki/lomines/data/config/teleport/TeleportConfig.java new file mode 100644 index 0000000..c468b63 --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/teleport/TeleportConfig.java @@ -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 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); + } +} diff --git a/src/main/java/dev/loki/lomines/data/config/ui/UIConfig.java b/src/main/java/dev/loki/lomines/data/config/ui/UIConfig.java new file mode 100644 index 0000000..bb82a87 --- /dev/null +++ b/src/main/java/dev/loki/lomines/data/config/ui/UIConfig.java @@ -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 = "{mine} {percent}% ({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); + } +} diff --git a/src/main/resources/defaults.yml b/src/main/resources/defaults.yml new file mode 100644 index 0000000..8ab0651 --- /dev/null +++ b/src/main/resources/defaults.yml @@ -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: "{mine} {percent}% ({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: "Lucky Diamond" + # lore: + # - "Mined from the depths" + # commands: + # - "eco give %player% 100" + # prevent-drops: true + +# Teleport players on reset +teleport: + enabled: false + location: "" diff --git a/src/test/java/dev/loki/lomines/data/config/MineConfigTest.java b/src/test/java/dev/loki/lomines/data/config/MineConfigTest.java new file mode 100644 index 0000000..0e3656f --- /dev/null +++ b/src/test/java/dev/loki/lomines/data/config/MineConfigTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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()); + } + } +} diff --git a/src/test/java/dev/loki/lomines/data/config/block/BlockConfigTest.java b/src/test/java/dev/loki/lomines/data/config/block/BlockConfigTest.java new file mode 100644 index 0000000..fe6e013 --- /dev/null +++ b/src/test/java/dev/loki/lomines/data/config/block/BlockConfigTest.java @@ -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 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 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 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 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 weights = new HashMap<>(); + weights.put(new BlockKey.Vanilla(Material.STONE), null); + + assertThrows(IllegalArgumentException.class, () -> + new BlockConfig(weights, FillMode.CUBOID, null)); + } + + @Test + void testNullBlockKeyThrows() { + Map weights = new HashMap<>(); + weights.put(null, 50.0); + + assertThrows(NullPointerException.class, () -> + new BlockConfig(weights, FillMode.CUBOID, null)); + } + + @Test + void testCustomBlocksDetected() { + Map 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 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 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 returnedWeights = config.weights(); + assertThrows(UnsupportedOperationException.class, () -> + returnedWeights.put(new BlockKey.Vanilla(Material.DIRT), 50.0)); + } + + @Test + void testMaskConfig() { + Map weights = new HashMap<>(); + weights.put(new BlockKey.Vanilla(Material.STONE), 100.0); + + BlockKey marker = new BlockKey.Vanilla(Material.PINK_CONCRETE); + Map 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 weights = new HashMap<>(); + weights.put(new BlockKey.Vanilla(Material.STONE), 100.0); + + assertThrows(NullPointerException.class, () -> + new BlockConfig(weights, null, null)); + } +} diff --git a/src/test/java/dev/loki/lomines/data/config/block/BlockKeyTest.java b/src/test/java/dev/loki/lomines/data/config/block/BlockKeyTest.java new file mode 100644 index 0000000..f96f331 --- /dev/null +++ b/src/test/java/dev/loki/lomines/data/config/block/BlockKeyTest.java @@ -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); + } +} diff --git a/src/test/java/dev/loki/lomines/data/config/reset/ResetConfigTest.java b/src/test/java/dev/loki/lomines/data/config/reset/ResetConfigTest.java new file mode 100644 index 0000000..53f9e90 --- /dev/null +++ b/src/test/java/dev/loki/lomines/data/config/reset/ResetConfigTest.java @@ -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()); + } +} diff --git a/src/test/java/dev/loki/lomines/data/config/ui/UIConfigTest.java b/src/test/java/dev/loki/lomines/data/config/ui/UIConfigTest.java new file mode 100644 index 0000000..ac216cf --- /dev/null +++ b/src/test/java/dev/loki/lomines/data/config/ui/UIConfigTest.java @@ -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, + "{mine}", + 100.0, + "HH:mm:ss" + ); + + assertTrue(config.actionBarEnabled()); + assertEquals("{mine}", 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)); + } +}