feat(config): add validated durable Tree Feller state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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