feat(config): add validated durable Tree Feller state

This commit is contained in:
dmg
2026-08-11 17:14:56 -04:00
parent 39fb3cfc57
commit 12a8e84535
15 changed files with 795 additions and 17 deletions
+8
View File
@@ -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`.
@@ -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
@@ -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);
}
}
}
@@ -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<String> knownNames,
boolean enabled,
boolean locked,
Map<TreeSpecies, Long> progress,
Set<TreeSpecies> 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<TreeSpecies, Long> 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<String> 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<TreeSpecies, Long> 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<TreeSpecies> 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<String> names,
boolean enabledValue,
boolean lockedValue,
Map<TreeSpecies, Long> progressValues,
Set<TreeSpecies> unlockedValues) {
return new PlayerTreeFellerState(
playerId, name, names, enabledValue, lockedValue, progressValues, unlockedValues);
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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<TreeSpecies, Integer> 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<String, String> 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<TreeSpecies, Integer> 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<TreeSpecies, Integer> 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<String, String> 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 extends Enum<E>> E requireEnum(
ConfigurationSection configuration, String path, Class<E> 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;
}
}
@@ -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;
}
}
@@ -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<TreeSpecies> 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();
}
}
@@ -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<PlayerTreeFellerState> 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<PlayerTreeFellerState> loadAll() {
ConfigurationSection players = document.getConfigurationSection("players");
if (players == null) {
return List.of();
}
List<PlayerTreeFellerState> 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<String> 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<TreeSpecies, Long> 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<TreeSpecies> 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;
}
}
+45 -1
View File
@@ -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."
@@ -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));
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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<PlayerTreeFellerState> 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<PlayerTreeFellerState> loaded = new YamlPlayerStateRepository(file).load(playerId);
assertTrue(loaded.isEmpty());
}
}