diff --git a/design/log.md b/design/log.md index 5c4ac76..76c5606 100644 --- a/design/log.md +++ b/design/log.md @@ -26,3 +26,11 @@ - Added the Java 17 Gradle project, Spigot 26.2 dependency, strict compilation, JUnit lifecycle, plugin metadata, wrapper, and Gitea CI and semantic-release workflows. - Verified separate player and administrative command metadata through a failing-then-passing test. - Verified the local foundation with `./gradlew clean check jar`; remote workflow and release criteria remain pending final delivery verification. + +### US-008 configuration and persistence completed + +- Added validated settings for all supported species, search safety, animation, progress presentation, undo, titles, and messages. +- Added persistence-before-activation threshold changes and failure-safe active settings. +- Added UUID-keyed immutable player state with retained names, saturating progress, unlocks, preferences, locks, defensive reads, forward-field retention, and atomic YAML replacement. +- Invalid required configuration now disables partial plugin startup with a focused log message. +- Verified settings, persistence, corruption handling, atomic replacement, and the complete build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-008-configure-and-persist-tree-feller.md b/design/user-stories/us-008-configure-and-persist-tree-feller.md index de0ca9a..15e22e4 100644 --- a/design/user-stories/us-008-configure-and-persist-tree-feller.md +++ b/design/user-stories/us-008-configure-and-persist-tree-feller.md @@ -2,7 +2,7 @@ type: User Story title: "US-008: Configure and persist Tree Feller" description: Provide validated configuration and durable player state for predictable tree-felling behavior. -status: backlog +status: done --- # US-008: Configure and persist Tree Feller @@ -11,21 +11,21 @@ As a **server operator**, I want Tree Feller's behavior and player progression s ## Acceptance criteria -- [ ] Configuration defines supported species and their thresholds, each defaulting to 100 qualifying blocks. -- [ ] Configuration defines an animation delay defaulting to two server ticks per felled block. -- [ ] Configuration defines a progress boss-bar timeout defaulting to five seconds and an undo window defaulting to six minutes. -- [ ] Configuration includes bounded tree-search limits, boss-bar presentation, title timing, and all player-facing messages. -- [ ] Numeric settings reject zero, negative, overflowing, or operationally unsafe values using documented bounds. -- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message identifying the setting. -- [ ] Runtime threshold changes made through `/treefelleradmin` are persisted before becoming active and survive restart. -- [ ] A failed runtime configuration write leaves the active threshold unchanged. -- [ ] UUID-keyed player state persists latest known name, enabled preference, administrative lock, per-species progress, and earned unlocks. -- [ ] Player identity resolution safely retains previously known names while UUID remains authoritative. -- [ ] Player state is written atomically where supported so that a failed write does not replace valid state with a partial document. -- [ ] Corrupt or invalid player records are handled defensively and cannot silently grant unlocks or administrative privileges. -- [ ] Progress that exceeds a currently configured threshold is retained, and counters cannot overflow. -- [ ] Unknown forward-compatible configuration and player-state fields are preserved where practical. -- [ ] Undo records are runtime safety records and do not survive a server restart unless a later approved design explicitly adds durable undo. +- [x] Configuration defines supported species and their thresholds, each defaulting to 100 qualifying blocks. +- [x] Configuration defines an animation delay defaulting to two server ticks per felled block. +- [x] Configuration defines a progress boss-bar timeout defaulting to five seconds and an undo window defaulting to six minutes. +- [x] Configuration includes bounded tree-search limits, boss-bar presentation, title timing, and all player-facing messages. +- [x] Numeric settings reject zero, negative, overflowing, or operationally unsafe values using documented bounds. +- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message identifying the setting. +- [x] Runtime threshold changes made through `/treefelleradmin` are persisted before becoming active and survive restart. +- [x] A failed runtime configuration write leaves the active threshold unchanged. +- [x] UUID-keyed player state persists latest known name, enabled preference, administrative lock, per-species progress, and earned unlocks. +- [x] Player identity resolution safely retains previously known names while UUID remains authoritative. +- [x] Player state is written atomically where supported so that a failed write does not replace valid state with a partial document. +- [x] Corrupt or invalid player records are handled defensively and cannot silently grant unlocks or administrative privileges. +- [x] Progress that exceeds a currently configured threshold is retained, and counters cannot overflow. +- [x] Unknown forward-compatible configuration and player-state fields are preserved where practical. +- [x] Undo records are runtime safety records and do not survive a server restart unless a later approved design explicitly adds durable undo. ## Related diff --git a/src/main/java/games/dmg/treefeller/BukkitThresholdPersistence.java b/src/main/java/games/dmg/treefeller/BukkitThresholdPersistence.java new file mode 100644 index 0000000..cb1a6ef --- /dev/null +++ b/src/main/java/games/dmg/treefeller/BukkitThresholdPersistence.java @@ -0,0 +1,28 @@ +package games.dmg.treefeller; + +import java.io.IOException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +/** Writes administrative threshold changes through Bukkit's configuration. */ +public final class BukkitThresholdPersistence implements ThresholdPersistence { + private final JavaPlugin plugin; + + public BukkitThresholdPersistence(JavaPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void save(TreeSpecies species, int threshold) throws IOException { + FileConfiguration configuration = plugin.getConfig(); + String path = "thresholds." + species.id(); + Object prior = configuration.get(path); + configuration.set(path, threshold); + try { + plugin.saveConfig(); + } catch (RuntimeException exception) { + configuration.set(path, prior); + throw new IOException("Unable to persist " + path, exception); + } + } +} diff --git a/src/main/java/games/dmg/treefeller/PlayerTreeFellerState.java b/src/main/java/games/dmg/treefeller/PlayerTreeFellerState.java new file mode 100644 index 0000000..8600d02 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/PlayerTreeFellerState.java @@ -0,0 +1,112 @@ +package games.dmg.treefeller; + +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Immutable durable state for one player. */ +public record PlayerTreeFellerState( + UUID playerId, + String latestName, + Set knownNames, + boolean enabled, + boolean locked, + Map progress, + Set unlocked) { + + public PlayerTreeFellerState { + Objects.requireNonNull(playerId, "playerId"); + if (latestName == null || latestName.isBlank()) { + throw new IllegalArgumentException("latestName must be non-empty"); + } + knownNames = Set.copyOf(knownNames); + if (!knownNames.contains(latestName)) { + throw new IllegalArgumentException("knownNames must contain latestName"); + } + EnumMap normalizedProgress = new EnumMap<>(TreeSpecies.class); + for (TreeSpecies species : TreeSpecies.values()) { + long value = progress.getOrDefault(species, 0L); + if (value < 0) { + throw new IllegalArgumentException("progress cannot be negative"); + } + normalizedProgress.put(species, value); + } + progress = Map.copyOf(normalizedProgress); + unlocked = Set.copyOf(unlocked); + } + + public static PlayerTreeFellerState initial(UUID playerId, String playerName) { + return new PlayerTreeFellerState( + playerId, + playerName, + Set.of(playerName), + true, + false, + Map.of(), + Set.of()); + } + + public long progress(TreeSpecies species) { + return progress.getOrDefault(species, 0L); + } + + public boolean isUnlocked(TreeSpecies species) { + return unlocked.contains(species); + } + + public PlayerTreeFellerState observeName(String playerName) { + if (playerName.equals(latestName)) { + return this; + } + LinkedHashSet names = new LinkedHashSet<>(knownNames); + names.add(playerName); + return copy(playerName, names, enabled, locked, progress, unlocked); + } + + public PlayerTreeFellerState withEnabled(boolean value) { + return copy(latestName, knownNames, value, locked, progress, unlocked); + } + + public PlayerTreeFellerState withLocked(boolean value) { + return copy(latestName, knownNames, enabled, value, progress, unlocked); + } + + public PlayerTreeFellerState withProgress(TreeSpecies species, long value) { + EnumMap changed = new EnumMap<>(TreeSpecies.class); + changed.putAll(progress); + changed.put(species, value); + return copy(latestName, knownNames, enabled, locked, changed, unlocked); + } + + public PlayerTreeFellerState incrementProgress(TreeSpecies species) { + long current = progress(species); + return current == Long.MAX_VALUE ? this : withProgress(species, current + 1L); + } + + public PlayerTreeFellerState withUnlocked(TreeSpecies species, boolean value) { + EnumSet changed = unlocked.isEmpty() + ? EnumSet.noneOf(TreeSpecies.class) + : EnumSet.copyOf(unlocked); + if (value) { + changed.add(species); + } else { + changed.remove(species); + } + return copy(latestName, knownNames, enabled, locked, progress, changed); + } + + private PlayerTreeFellerState copy( + String name, + Set names, + boolean enabledValue, + boolean lockedValue, + Map progressValues, + Set unlockedValues) { + return new PlayerTreeFellerState( + playerId, name, names, enabledValue, lockedValue, progressValues, unlockedValues); + } +} diff --git a/src/main/java/games/dmg/treefeller/ThresholdPersistence.java b/src/main/java/games/dmg/treefeller/ThresholdPersistence.java new file mode 100644 index 0000000..550de70 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/ThresholdPersistence.java @@ -0,0 +1,9 @@ +package games.dmg.treefeller; + +import java.io.IOException; + +/** Durable write boundary for a live threshold change. */ +@FunctionalInterface +public interface ThresholdPersistence { + void save(TreeSpecies species, int threshold) throws IOException; +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index 8749406..367f26c 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -1,11 +1,33 @@ package games.dmg.treefeller; +import java.nio.file.Path; import org.bukkit.plugin.java.JavaPlugin; /** Entry point for Tree Feller. */ public final class TreeFellerPlugin extends JavaPlugin { + private TreeFellerSettingsService settingsService; + private YamlPlayerStateRepository playerStateRepository; + @Override public void onEnable() { saveDefaultConfig(); + try { + TreeFellerSettings settings = TreeFellerSettings.load(getConfig()); + settingsService = new TreeFellerSettingsService( + settings, new BukkitThresholdPersistence(this)); + Path playerStateFile = getDataFolder().toPath().resolve("players.yml"); + playerStateRepository = new YamlPlayerStateRepository(playerStateFile); + } catch (IllegalArgumentException exception) { + getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage()); + getServer().getPluginManager().disablePlugin(this); + } + } + + public TreeFellerSettingsService settingsService() { + return settingsService; + } + + public YamlPlayerStateRepository playerStateRepository() { + return playerStateRepository; } } diff --git a/src/main/java/games/dmg/treefeller/TreeFellerSettings.java b/src/main/java/games/dmg/treefeller/TreeFellerSettings.java new file mode 100644 index 0000000..60c2595 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellerSettings.java @@ -0,0 +1,138 @@ +package games.dmg.treefeller; + +import java.util.EnumMap; +import java.util.Map; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.configuration.ConfigurationSection; + +/** Validated immutable runtime settings. */ +public record TreeFellerSettings( + Map thresholds, + int animationDelayTicks, + int bossBarTimeoutSeconds, + String bossBarText, + BarColor bossBarColor, + BarStyle bossBarStyle, + int undoWindowMinutes, + int maxSearchBlocks, + int maxSearchDistance, + String titleText, + String subtitleText, + int titleFadeInTicks, + int titleStayTicks, + int titleFadeOutTicks, + Map messages) { + + public TreeFellerSettings { + thresholds = Map.copyOf(thresholds); + messages = Map.copyOf(messages); + } + + public int threshold(TreeSpecies species) { + Integer threshold = thresholds.get(species); + if (threshold == null) { + throw new IllegalArgumentException("No threshold configured for " + species.id()); + } + return threshold; + } + + public String message(String key) { + String message = messages.get(key); + if (message == null) { + throw new IllegalArgumentException("No message configured for " + key); + } + return message; + } + + public TreeFellerSettings withThreshold(TreeSpecies species, int threshold) { + requireRange("thresholds." + species.id(), threshold, 1, 1_000_000); + EnumMap changed = new EnumMap<>(TreeSpecies.class); + changed.putAll(thresholds); + changed.put(species, threshold); + return new TreeFellerSettings( + changed, + animationDelayTicks, + bossBarTimeoutSeconds, + bossBarText, + bossBarColor, + bossBarStyle, + undoWindowMinutes, + maxSearchBlocks, + maxSearchDistance, + titleText, + subtitleText, + titleFadeInTicks, + titleStayTicks, + titleFadeOutTicks, + messages); + } + + public static TreeFellerSettings load(ConfigurationSection configuration) { + EnumMap thresholds = new EnumMap<>(TreeSpecies.class); + for (TreeSpecies species : TreeSpecies.values()) { + String path = "thresholds." + species.id(); + thresholds.put(species, requireRange(path, configuration.getInt(path), 1, 1_000_000)); + } + + int delay = requireRange("animation.delay-ticks", configuration.getInt("animation.delay-ticks"), 1, 1200); + int bossTimeout = requireRange("boss-bar.timeout-seconds", configuration.getInt("boss-bar.timeout-seconds"), 1, 3600); + int undoWindow = requireRange("undo.window-minutes", configuration.getInt("undo.window-minutes"), 1, 1440); + int maxBlocks = requireRange("search.max-blocks", configuration.getInt("search.max-blocks"), 1, 100_000); + int maxDistance = requireRange("search.max-distance", configuration.getInt("search.max-distance"), 1, 512); + int fadeIn = requireRange("title.fade-in-ticks", configuration.getInt("title.fade-in-ticks"), 0, 1200); + int stay = requireRange("title.stay-ticks", configuration.getInt("title.stay-ticks"), 1, 12_000); + int fadeOut = requireRange("title.fade-out-ticks", configuration.getInt("title.fade-out-ticks"), 0, 1200); + + Map messages = Map.of( + "unlock-guidance", requireText(configuration, "messages.unlock-guidance"), + "enabled", requireText(configuration, "messages.enabled"), + "disabled", requireText(configuration, "messages.disabled"), + "administratively-locked", requireText(configuration, "messages.administratively-locked"), + "no-undo", requireText(configuration, "messages.no-undo"), + "undo-expired", requireText(configuration, "messages.undo-expired")); + + return new TreeFellerSettings( + thresholds, + delay, + bossTimeout, + requireText(configuration, "boss-bar.text"), + requireEnum(configuration, "boss-bar.color", BarColor.class), + requireEnum(configuration, "boss-bar.style", BarStyle.class), + undoWindow, + maxBlocks, + maxDistance, + requireText(configuration, "title.text"), + requireText(configuration, "title.subtitle"), + fadeIn, + stay, + fadeOut, + messages); + } + + private static String requireText(ConfigurationSection configuration, String path) { + String value = configuration.getString(path); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(path + " must be non-empty text"); + } + return value; + } + + private static > E requireEnum( + ConfigurationSection configuration, String path, Class type) { + String value = requireText(configuration, path); + try { + return Enum.valueOf(type, value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException(path + " has invalid value " + value, exception); + } + } + + private static int requireRange(String path, int value, int minimum, int maximum) { + if (value < minimum || value > maximum) { + throw new IllegalArgumentException( + path + " must be between " + minimum + " and " + maximum); + } + return value; + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerSettingsService.java b/src/main/java/games/dmg/treefeller/TreeFellerSettingsService.java new file mode 100644 index 0000000..4d0925c --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellerSettingsService.java @@ -0,0 +1,26 @@ +package games.dmg.treefeller; + +import java.io.IOException; +import java.util.Objects; + +/** Persists a threshold before exposing it as active runtime state. */ +public final class TreeFellerSettingsService { + private TreeFellerSettings current; + private final ThresholdPersistence persistence; + + public TreeFellerSettingsService( + TreeFellerSettings initialSettings, ThresholdPersistence persistence) { + this.current = Objects.requireNonNull(initialSettings, "initialSettings"); + this.persistence = Objects.requireNonNull(persistence, "persistence"); + } + + public TreeFellerSettings current() { + return current; + } + + public void changeThreshold(TreeSpecies species, int threshold) throws IOException { + TreeFellerSettings changed = current.withThreshold(species, threshold); + persistence.save(species, threshold); + current = changed; + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeSpecies.java b/src/main/java/games/dmg/treefeller/TreeSpecies.java new file mode 100644 index 0000000..cc0a471 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeSpecies.java @@ -0,0 +1,46 @@ +package games.dmg.treefeller; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; + +/** Stable identifiers for independently unlockable tree families. */ +public enum TreeSpecies { + OAK("oak", "Oak"), + SPRUCE("spruce", "Spruce"), + BIRCH("birch", "Birch"), + JUNGLE("jungle", "Jungle"), + ACACIA("acacia", "Acacia"), + DARK_OAK("dark-oak", "Dark Oak"), + MANGROVE("mangrove", "Mangrove"), + CHERRY("cherry", "Cherry"), + PALE_OAK("pale-oak", "Pale Oak"), + CRIMSON("crimson", "Crimson Fungus"), + WARPED("warped", "Warped Fungus"), + RED_MUSHROOM("red-mushroom", "Giant Red Mushroom"), + BROWN_MUSHROOM("brown-mushroom", "Giant Brown Mushroom"); + + private final String id; + private final String displayName; + + TreeSpecies(String id, String displayName) { + this.id = id; + this.displayName = displayName; + } + + public String id() { + return id; + } + + public String displayName() { + return displayName; + } + + public static Optional fromId(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.toLowerCase(Locale.ROOT); + return Arrays.stream(values()).filter(species -> species.id.equals(normalized)).findFirst(); + } +} diff --git a/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java b/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java new file mode 100644 index 0000000..a5e28ff --- /dev/null +++ b/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java @@ -0,0 +1,154 @@ +package games.dmg.treefeller; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +/** UUID-keyed YAML persistence that retains fields it does not own. */ +public final class YamlPlayerStateRepository { + private final Path file; + private final YamlConfiguration document; + + public YamlPlayerStateRepository(Path file) { + this.file = file; + this.document = Files.exists(file) + ? YamlConfiguration.loadConfiguration(file.toFile()) + : new YamlConfiguration(); + } + + public Optional load(UUID playerId) { + ConfigurationSection record = document.getConfigurationSection(path(playerId)); + if (record == null) { + return Optional.empty(); + } + try { + return Optional.of(read(playerId, record)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + public List loadAll() { + ConfigurationSection players = document.getConfigurationSection("players"); + if (players == null) { + return List.of(); + } + List states = new ArrayList<>(); + for (String key : players.getKeys(false)) { + try { + UUID playerId = UUID.fromString(key); + load(playerId).ifPresent(states::add); + } catch (IllegalArgumentException ignored) { + // Invalid records are deliberately excluded rather than trusted. + } + } + return List.copyOf(states); + } + + public void save(PlayerTreeFellerState state) throws IOException { + String root = path(state.playerId()); + document.set(root + ".latest-name", state.latestName()); + document.set(root + ".known-names", new ArrayList<>(state.knownNames())); + document.set(root + ".enabled", state.enabled()); + document.set(root + ".locked", state.locked()); + for (TreeSpecies species : TreeSpecies.values()) { + document.set(root + ".progress." + species.id(), state.progress(species)); + } + document.set( + root + ".unlocked", + state.unlocked().stream().map(TreeSpecies::id).sorted().toList()); + writeAtomically(); + } + + private PlayerTreeFellerState read(UUID playerId, ConfigurationSection record) { + String latestName = record.getString("latest-name"); + if (latestName == null || latestName.isBlank()) { + throw new IllegalArgumentException("missing latest name"); + } + if (!record.isBoolean("enabled") || !record.isBoolean("locked")) { + throw new IllegalArgumentException("invalid player flags"); + } + + List rawNames = record.getList("known-names"); + if (rawNames == null) { + throw new IllegalArgumentException("missing known names"); + } + LinkedHashSet names = new LinkedHashSet<>(); + for (Object rawName : rawNames) { + if (!(rawName instanceof String name) || name.isBlank()) { + throw new IllegalArgumentException("invalid known name"); + } + names.add(name); + } + names.add(latestName); + + EnumMap progress = new EnumMap<>(TreeSpecies.class); + ConfigurationSection progressSection = record.getConfigurationSection("progress"); + for (TreeSpecies species : TreeSpecies.values()) { + long value = progressSection == null ? 0L : progressSection.getLong(species.id(), 0L); + if (value < 0) { + throw new IllegalArgumentException("negative progress"); + } + progress.put(species, value); + } + + List rawUnlocks = record.getList("unlocked"); + if (rawUnlocks == null) { + throw new IllegalArgumentException("missing unlocks"); + } + Set unlocked = EnumSet.noneOf(TreeSpecies.class); + for (Object rawUnlock : rawUnlocks) { + if (!(rawUnlock instanceof String identifier)) { + throw new IllegalArgumentException("invalid unlock"); + } + TreeSpecies species = TreeSpecies.fromId(identifier) + .orElseThrow(() -> new IllegalArgumentException("unknown unlock")); + unlocked.add(species); + } + + return new PlayerTreeFellerState( + playerId, + latestName, + names, + record.getBoolean("enabled"), + record.getBoolean("locked"), + progress, + unlocked); + } + + private void writeAtomically() throws IOException { + Path parent = file.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Path temporary = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(temporary, document.saveToString(), StandardCharsets.UTF_8); + try { + Files.move( + temporary, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + private String path(UUID playerId) { + return "players." + playerId; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9833001..68d6302 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1 +1,45 @@ -# Tree Feller settings are introduced and validated by US-008. +thresholds: + oak: 100 + spruce: 100 + birch: 100 + jungle: 100 + acacia: 100 + dark-oak: 100 + mangrove: 100 + cherry: 100 + pale-oak: 100 + crimson: 100 + warped: 100 + red-mushroom: 100 + brown-mushroom: 100 + +animation: + delay-ticks: 2 + +boss-bar: + timeout-seconds: 5 + text: "&a{species}: {progress}/{threshold}" + color: GREEN + style: SOLID + +undo: + window-minutes: 6 + +search: + max-blocks: 1024 + max-distance: 64 + +title: + text: "&a{species} unlocked!" + subtitle: "&fYou can now fell this tree type." + fade-in-ticks: 10 + stay-ticks: 70 + fade-out-ticks: 20 + +messages: + unlock-guidance: "&aSneak while chopping to prevent Tree Feller, or use /treefeller undo after a mistake." + enabled: "&aTree Feller is enabled." + disabled: "&eTree Feller is disabled." + administratively-locked: "&cAn administrator has locked automatic tree felling for you." + no-undo: "&eYou do not have a tree available to undo." + undo-expired: "&eYour last tree can no longer be undone." diff --git a/src/test/java/games/dmg/treefeller/PlayerTreeFellerStateTest.java b/src/test/java/games/dmg/treefeller/PlayerTreeFellerStateTest.java new file mode 100644 index 0000000..7dfdbfe --- /dev/null +++ b/src/test/java/games/dmg/treefeller/PlayerTreeFellerStateTest.java @@ -0,0 +1,18 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class PlayerTreeFellerStateTest { + @Test + void progressSaturatesInsteadOfOverflowing() { + PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player") + .withProgress(TreeSpecies.OAK, Long.MAX_VALUE); + + PlayerTreeFellerState changed = state.incrementProgress(TreeSpecies.OAK); + + assertEquals(Long.MAX_VALUE, changed.progress(TreeSpecies.OAK)); + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellerSettingsServiceTest.java b/src/test/java/games/dmg/treefeller/TreeFellerSettingsServiceTest.java new file mode 100644 index 0000000..f5f2b31 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeFellerSettingsServiceTest.java @@ -0,0 +1,63 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class TreeFellerSettingsServiceTest { + @Test + void activatesAThresholdOnlyAfterPersistenceSucceeds() throws Exception { + TreeFellerSettings settings = defaults(); + RecordingPersistence persistence = new RecordingPersistence(false); + TreeFellerSettingsService service = new TreeFellerSettingsService(settings, persistence); + + service.changeThreshold(TreeSpecies.OAK, 25); + + assertEquals(TreeSpecies.OAK, persistence.species); + assertEquals(25, persistence.threshold); + assertEquals(25, service.current().threshold(TreeSpecies.OAK)); + } + + @Test + void retainsActiveSettingsWhenPersistenceFails() throws Exception { + TreeFellerSettings settings = defaults(); + TreeFellerSettingsService service = new TreeFellerSettingsService( + settings, new RecordingPersistence(true)); + + assertThrows(IOException.class, () -> service.changeThreshold(TreeSpecies.OAK, 25)); + + assertEquals(100, service.current().threshold(TreeSpecies.OAK)); + } + + private TreeFellerSettings defaults() throws Exception { + try (InputStreamReader reader = new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"), + StandardCharsets.UTF_8)) { + return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader)); + } + } + + private static final class RecordingPersistence implements ThresholdPersistence { + private final boolean fail; + private TreeSpecies species; + private int threshold; + + private RecordingPersistence(boolean fail) { + this.fail = fail; + } + + @Override + public void save(TreeSpecies changedSpecies, int changedThreshold) throws IOException { + if (fail) { + throw new IOException("disk unavailable"); + } + species = changedSpecies; + threshold = changedThreshold; + } + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellerSettingsTest.java b/src/test/java/games/dmg/treefeller/TreeFellerSettingsTest.java new file mode 100644 index 0000000..3015522 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeFellerSettingsTest.java @@ -0,0 +1,46 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class TreeFellerSettingsTest { + @Test + void loadsDocumentedDefaultsForEverySpecies() throws Exception { + YamlConfiguration configuration = loadDefaults(); + + TreeFellerSettings settings = TreeFellerSettings.load(configuration); + + for (TreeSpecies species : TreeSpecies.values()) { + assertEquals(100, settings.threshold(species)); + } + assertEquals(2, settings.animationDelayTicks()); + assertEquals(5, settings.bossBarTimeoutSeconds()); + assertEquals(6, settings.undoWindowMinutes()); + } + + @Test + void rejectsUnsafeRequiredNumbers() throws Exception { + YamlConfiguration configuration = loadDefaults(); + configuration.set("animation.delay-ticks", 0); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> TreeFellerSettings.load(configuration)); + + assertEquals("animation.delay-ticks must be between 1 and 1200", error.getMessage()); + } + + private YamlConfiguration loadDefaults() throws Exception { + try (Reader reader = new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"), + StandardCharsets.UTF_8)) { + return YamlConfiguration.loadConfiguration(reader); + } + } +} diff --git a/src/test/java/games/dmg/treefeller/YamlPlayerStateRepositoryTest.java b/src/test/java/games/dmg/treefeller/YamlPlayerStateRepositoryTest.java new file mode 100644 index 0000000..278de85 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/YamlPlayerStateRepositoryTest.java @@ -0,0 +1,64 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class YamlPlayerStateRepositoryTest { + @TempDir + Path temporaryDirectory; + + @Test + void roundTripsUuidKeyedStateAndPreservesForwardFields() throws Exception { + Path file = temporaryDirectory.resolve("players.yml"); + UUID playerId = UUID.randomUUID(); + YamlConfiguration existing = new YamlConfiguration(); + existing.set("future-root", "retained"); + existing.set("players." + playerId + ".future-player", 42); + existing.save(file.toFile()); + YamlPlayerStateRepository repository = new YamlPlayerStateRepository(file); + PlayerTreeFellerState state = PlayerTreeFellerState.initial(playerId, "OldName") + .observeName("CurrentName") + .withEnabled(false) + .withLocked(true) + .withProgress(TreeSpecies.OAK, 73) + .withUnlocked(TreeSpecies.BIRCH, true); + + repository.save(state); + Optional loaded = new YamlPlayerStateRepository(file).load(playerId); + + assertTrue(loaded.isPresent()); + assertEquals(state, loaded.orElseThrow()); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(file.toFile()); + assertEquals("retained", saved.getString("future-root")); + assertEquals(42, saved.getInt("players." + playerId + ".future-player")); + assertFalse(Files.exists(file.resolveSibling("players.yml.tmp"))); + } + + @Test + void rejectsARecordContainingUnknownUnlocksInsteadOfGrantingAccess() throws Exception { + Path file = temporaryDirectory.resolve("players.yml"); + UUID playerId = UUID.randomUUID(); + String root = "players." + playerId; + YamlConfiguration corrupt = new YamlConfiguration(); + corrupt.set(root + ".latest-name", "Player"); + corrupt.set(root + ".known-names", java.util.List.of("Player")); + corrupt.set(root + ".enabled", true); + corrupt.set(root + ".locked", false); + corrupt.set(root + ".progress.oak", 10); + corrupt.set(root + ".unlocked", java.util.List.of("oak", "future-admin-tree")); + corrupt.save(file.toFile()); + + Optional loaded = new YamlPlayerStateRepository(file).load(playerId); + + assertTrue(loaded.isEmpty()); + } +}