feat(config): add validated durable Tree Feller state
This commit is contained in:
@@ -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.
|
- 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 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.
|
- 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
|
type: User Story
|
||||||
title: "US-008: Configure and persist Tree Feller"
|
title: "US-008: Configure and persist Tree Feller"
|
||||||
description: Provide validated configuration and durable player state for predictable tree-felling behavior.
|
description: Provide validated configuration and durable player state for predictable tree-felling behavior.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-008: Configure and persist Tree Feller
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Configuration defines supported species and their thresholds, each defaulting to 100 qualifying blocks.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] Runtime threshold changes made through `/treefelleradmin` are persisted before becoming active and survive restart.
|
||||||
- [ ] A failed runtime configuration write leaves the active threshold unchanged.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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] Undo records are runtime safety records and do not survive a server restart unless a later approved design explicitly adds durable undo.
|
||||||
|
|
||||||
## Related
|
## 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;
|
package games.dmg.treefeller;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
/** Entry point for Tree Feller. */
|
/** Entry point for Tree Feller. */
|
||||||
public final class TreeFellerPlugin extends JavaPlugin {
|
public final class TreeFellerPlugin extends JavaPlugin {
|
||||||
|
private TreeFellerSettingsService settingsService;
|
||||||
|
private YamlPlayerStateRepository playerStateRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
saveDefaultConfig();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user