feat(config): add validated durable Leaf state

This commit is contained in:
dmg
2026-08-10 17:57:09 -04:00
parent 5627de6afe
commit f805f9371d
13 changed files with 605 additions and 9 deletions
+7
View File
@@ -20,3 +20,10 @@
- Established the Java 17 Gradle project, strict compilation, JUnit test lifecycle, Leaf plugin metadata, Gradle wrapper, and Gitea CI and semantic-release workflows.
- Verified the initial pipeline with `./gradlew clean check jar` and produced `build/libs/leaf-0.1.0-SNAPSHOT.jar`.
- US-007 remains in progress until the story-by-story implementation commit requirement has been verified.
### Configuration and persistence checkpoint
- Added validated settings with Resistance I and seven-day onboarding defaults.
- Added defensive UUID-keyed YAML player state with RFC 3339 timestamps, forward-field preservation, and atomic replacement where supported.
- Added safe plugin initialization and periodic dirty-state persistence.
- US-006 remains in progress pending live runtime setting commands and Leaf effect ownership behavior.
@@ -2,7 +2,7 @@
type: User Story
title: "US-006: Configure and persist Leaf"
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
status: backlog
status: in-progress
---
# US-006: Configure and persist Leaf
@@ -11,16 +11,16 @@ As a **server operator**, I want Leaf settings and player state to be validated
## Acceptance criteria
- [ ] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
- [ ] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
- [x] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
- [x] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
- [ ] Runtime changes made through `/leaf enabled` and `/leaf strength` are persisted for subsequent restarts.
- [ ] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
- [ ] Date-times use RFC 3339 UTC notation with a `Z` suffix.
- [ ] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [ ] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
- [x] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
- [x] Date-times use RFC 3339 UTC notation with a `Z` suffix.
- [x] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
- [ ] Removing Leaf-managed Resistance does not remove a distinct Resistance effect that Leaf does not own when the API provides enough information to distinguish it.
- [ ] 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.
## Related
@@ -0,0 +1,14 @@
package games.dmg.leaf;
import java.util.Map;
import java.util.UUID;
public record LeafPersistentState(Map<UUID, PlayerLeafState> players) {
public LeafPersistentState {
players = players == null ? Map.of() : Map.copyOf(players);
}
public static LeafPersistentState empty() {
return new LeafPersistentState(Map.of());
}
}
@@ -1,10 +1,61 @@
package games.dmg.leaf;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
public final class LeafPlugin extends JavaPlugin {
private LeafSettingsProvider settingsProvider;
private LeafStateManager stateManager;
@Override
public void onEnable() {
saveDefaultConfig();
try {
Map<String, Object> values = getConfig().getValues(false);
settingsProvider = new LeafSettingsProvider(LeafSettings.from(values));
stateManager = new LeafStateManager(
new YamlLeafStateRepository(getDataFolder().toPath().resolve("state.yml"))
);
} catch (IllegalArgumentException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Leaf", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
getLogger().info("Leaf enabled.");
}
@Override
public void onDisable() {
saveState();
}
LeafSettingsProvider settingsProvider() {
return settingsProvider;
}
LeafStateManager stateManager() {
return stateManager;
}
void persistRuntimeSettings() {
LeafSettings settings = settingsProvider.current();
getConfig().set("enabled", settings.enabled());
getConfig().set("resistance-level", settings.resistanceLevel());
saveConfig();
}
private void saveState() {
if (stateManager == null) {
return;
}
try {
stateManager.saveIfDirty();
} catch (IOException exception) {
getLogger().log(Level.SEVERE, "Could not save Leaf player state", exception);
}
}
}
@@ -0,0 +1,112 @@
package games.dmg.leaf;
import java.util.Map;
import java.util.Objects;
public record LeafSettings(
boolean enabled,
int resistanceLevel,
String prefix,
int onboardingDays,
String welcomeMessage,
String combatDisabledMessage,
String lockedMessage
) {
public LeafSettings {
if (resistanceLevel < 1 || resistanceLevel > 5) {
throw new IllegalArgumentException("resistance-level must be between 1 and 5");
}
if (onboardingDays <= 0) {
throw new IllegalArgumentException("onboarding-days must be positive");
}
prefix = required(prefix, "prefix");
welcomeMessage = required(welcomeMessage, "welcome-message");
combatDisabledMessage = required(combatDisabledMessage, "combat-disabled-message");
lockedMessage = required(lockedMessage, "locked-message");
}
public static LeafSettings from(Map<String, ?> values) {
Objects.requireNonNull(values, "values");
return new LeafSettings(
bool(values, "enabled", true),
integer(values, "resistance-level", 1),
string(values, "prefix", "&a🍃 "),
integer(values, "onboarding-days", 7),
string(
values,
"welcome-message",
"&aLeaf protection is available: /leaf on, /leaf off, or /leaf status. "
+ "Attacking another player opts you out."
),
string(
values,
"combat-disabled-message",
"&cLeaf protection was disabled because you attacked another player."
),
string(values, "locked-message", "&cAn administrator locked your Leaf setting.")
);
}
public LeafSettings withEnabled(boolean newEnabled) {
return new LeafSettings(
newEnabled,
resistanceLevel,
prefix,
onboardingDays,
welcomeMessage,
combatDisabledMessage,
lockedMessage
);
}
public LeafSettings withResistanceLevel(int level) {
return new LeafSettings(
enabled,
level,
prefix,
onboardingDays,
welcomeMessage,
combatDisabledMessage,
lockedMessage
);
}
private static boolean bool(Map<String, ?> values, String key, boolean defaultValue) {
Object value = values.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof Boolean booleanValue)) {
throw new IllegalArgumentException(key + " must be true or false");
}
return booleanValue;
}
private static int integer(Map<String, ?> values, String key, int defaultValue) {
Object value = values.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof Number number)) {
throw new IllegalArgumentException(key + " must be an integer");
}
double decimal = number.doubleValue();
if (!Double.isFinite(decimal) || decimal != Math.rint(decimal)
|| decimal > Integer.MAX_VALUE || decimal < Integer.MIN_VALUE) {
throw new IllegalArgumentException(key + " must be an integer");
}
return (int) decimal;
}
private static String string(Map<String, ?> values, String key, String defaultValue) {
Object value = values.get(key);
return value == null ? defaultValue : value.toString();
}
private static String required(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(key + " must not be blank");
}
return value;
}
}
@@ -0,0 +1,19 @@
package games.dmg.leaf;
import java.util.Objects;
public final class LeafSettingsProvider {
private LeafSettings current;
public LeafSettingsProvider(LeafSettings initial) {
current = Objects.requireNonNull(initial, "initial");
}
public synchronized LeafSettings current() {
return current;
}
public synchronized void replace(LeafSettings replacement) {
current = Objects.requireNonNull(replacement, "replacement");
}
}
@@ -0,0 +1,71 @@
package games.dmg.leaf;
import java.io.IOException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;
public final class LeafStateManager {
private final YamlLeafStateRepository repository;
private final Map<UUID, PlayerLeafState> players;
private boolean dirty;
public LeafStateManager(YamlLeafStateRepository repository) throws IOException {
this.repository = repository;
this.players = new HashMap<>(repository.load().players());
}
public synchronized PlayerLeafState observePlayer(
UUID playerId,
String latestName,
Instant observedAt
) {
PlayerLeafState existing = players.get(playerId);
PlayerLeafState observed = existing == null
? PlayerLeafState.newPlayer(playerId, latestName, observedAt)
: existing.withLatestName(latestName);
if (!observed.equals(existing)) {
players.put(playerId, observed);
dirty = true;
}
return observed;
}
public synchronized Optional<PlayerLeafState> find(UUID playerId) {
return Optional.ofNullable(players.get(playerId));
}
public synchronized Map<UUID, PlayerLeafState> players() {
return Map.copyOf(players);
}
public synchronized PlayerLeafState update(
UUID playerId,
UnaryOperator<PlayerLeafState> change
) {
PlayerLeafState existing = players.get(playerId);
if (existing == null) {
throw new IllegalArgumentException("unknown player: " + playerId);
}
PlayerLeafState updated = change.apply(existing);
if (!playerId.equals(updated.playerId())) {
throw new IllegalArgumentException("an update cannot change the player ID");
}
if (!updated.equals(existing)) {
players.put(playerId, updated);
dirty = true;
}
return updated;
}
public synchronized void saveIfDirty() throws IOException {
if (!dirty) {
return;
}
repository.save(new LeafPersistentState(players));
dirty = false;
}
}
@@ -0,0 +1,40 @@
package games.dmg.leaf;
import java.time.Instant;
import java.util.UUID;
public record PlayerLeafState(
UUID playerId,
String latestName,
boolean optedIn,
boolean locked,
Instant firstJoin
) {
public PlayerLeafState {
if (playerId == null) {
throw new IllegalArgumentException("player ID is required");
}
if (latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("latest player name is required");
}
if (firstJoin == null) {
throw new IllegalArgumentException("first join time is required");
}
}
public static PlayerLeafState newPlayer(UUID playerId, String latestName, Instant joinedAt) {
return new PlayerLeafState(playerId, latestName, false, false, joinedAt);
}
public PlayerLeafState withLatestName(String name) {
return new PlayerLeafState(playerId, name, optedIn, locked, firstJoin);
}
public PlayerLeafState withOptedIn(boolean enabled) {
return new PlayerLeafState(playerId, latestName, enabled, locked, firstJoin);
}
public PlayerLeafState withLocked(boolean newLocked) {
return new PlayerLeafState(playerId, latestName, optedIn, newLocked, firstJoin);
}
}
@@ -0,0 +1,131 @@
package games.dmg.leaf;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class YamlLeafStateRepository {
private final Path stateFile;
public YamlLeafStateRepository(Path stateFile) {
this.stateFile = stateFile;
}
public LeafPersistentState load() throws IOException {
if (!Files.exists(stateFile)) {
return LeafPersistentState.empty();
}
return new LeafPersistentState(loadPlayers(loadYaml(stateFile)));
}
public void save(LeafPersistentState state) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent == null) {
throw new IOException("state file must have a parent directory");
}
Files.createDirectories(parent);
YamlConfiguration yaml = Files.exists(stateFile)
? loadYaml(stateFile)
: new YamlConfiguration();
removeAbsentPlayers(yaml, state.players());
savePlayers(yaml, state.players());
Path temporary = Files.createTempFile(parent, "leaf-state-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static YamlConfiguration loadYaml(Path path) throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(path.toFile());
return yaml;
} catch (InvalidConfigurationException exception) {
throw new IOException("state file is not valid YAML", exception);
}
}
private static Map<UUID, PlayerLeafState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerLeafState> players = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection("players");
if (section == null) {
return players;
}
for (String key : section.getKeys(false)) {
try {
UUID playerId = UUID.fromString(key);
String path = "players." + key;
String name = yaml.getString(path + ".name");
String firstJoinValue = yaml.getString(path + ".first-join");
if (name == null || firstJoinValue == null) {
continue;
}
PlayerLeafState player = new PlayerLeafState(
playerId,
name,
yaml.getBoolean(path + ".opted-in", false),
yaml.getBoolean(path + ".locked", false),
Instant.parse(firstJoinValue)
);
players.put(playerId, player);
} catch (IllegalArgumentException | DateTimeParseException ignored) {
// Invalid records are ignored rather than granting protection or privileges.
}
}
return players;
}
private static void removeAbsentPlayers(
YamlConfiguration yaml,
Map<UUID, PlayerLeafState> players
) {
ConfigurationSection section = yaml.getConfigurationSection("players");
if (section == null) {
return;
}
for (String key : section.getKeys(false)) {
try {
if (!players.containsKey(UUID.fromString(key))) {
yaml.set("players." + key, null);
}
} catch (IllegalArgumentException exception) {
yaml.set("players." + key, null);
}
}
}
private static void savePlayers(
YamlConfiguration yaml,
Map<UUID, PlayerLeafState> players
) {
for (PlayerLeafState player : players.values()) {
String path = "players." + player.playerId();
yaml.set(path + ".name", player.latestName());
yaml.set(path + ".opted-in", player.optedIn());
yaml.set(path + ".locked", player.locked());
yaml.set(path + ".first-join", player.firstJoin().toString());
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# Whether Leaf protection is available server-wide.
enabled: true
# Visible Minecraft Resistance level (I-V).
resistance-level: 1
# Legacy color codes are supported.
prefix: "&a🍃 "
# Calendar-day onboarding window measured from first join.
onboarding-days: 7
welcome-message: "&aLeaf protection is available: /leaf on, /leaf off, or /leaf status. Attacking another player opts you out."
combat-disabled-message: "&cLeaf protection was disabled because you attacked another player."
locked-message: "&cAn administrator locked your Leaf setting."
@@ -0,0 +1,27 @@
package games.dmg.leaf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import org.junit.jupiter.api.Test;
final class LeafSettingsTest {
@Test
void suppliesSafeDefaults() {
LeafSettings settings = LeafSettings.from(Map.of());
assertEquals(true, settings.enabled());
assertEquals(1, settings.resistanceLevel());
assertEquals("&a🍃 ", settings.prefix());
assertEquals(7, settings.onboardingDays());
}
@Test
void rejectsResistanceOutsideMinecraftLevels() {
assertThrows(
IllegalArgumentException.class,
() -> LeafSettings.from(Map.of("resistance-level", 6))
);
}
}
@@ -0,0 +1,31 @@
package games.dmg.leaf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class LeafStateManagerTest {
@TempDir
Path temporaryDirectory;
@Test
void keepsOriginalFirstJoinWhenPlayerReturns() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
LeafStateManager manager = new LeafStateManager(repository);
UUID playerId = UUID.randomUUID();
Instant first = Instant.parse("2026-08-01T00:00:00Z");
manager.observePlayer(playerId, "Alex", first);
manager.observePlayer(playerId, "AlexNew", Instant.parse("2026-08-10T00:00:00Z"));
manager.saveIfDirty();
PlayerLeafState saved = repository.load().players().get(playerId);
assertEquals(first, saved.firstJoin());
assertEquals("AlexNew", saved.latestName());
}
}
@@ -0,0 +1,78 @@
package games.dmg.leaf;
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.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlLeafStateRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsPlayerStateWithRfc3339Timestamp() throws Exception {
UUID playerId = UUID.randomUUID();
PlayerLeafState player = new PlayerLeafState(
playerId,
"Alex",
true,
true,
Instant.parse("2026-08-10T12:34:56Z")
);
LeafPersistentState expected = new LeafPersistentState(Map.of(playerId, player));
Path stateFile = temporaryDirectory.resolve("state.yml");
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
repository.save(expected);
assertEquals(expected, repository.load());
assertTrue(Files.readString(stateFile).contains("2026-08-10T12:34:56Z"));
}
@Test
void ignoresInvalidRecordsRatherThanGrantingProtection() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
opted-in: true
first-join: not-a-timestamp
""".formatted(playerId));
LeafPersistentState loaded = new YamlLeafStateRepository(stateFile).load();
assertFalse(loaded.players().containsKey(playerId));
}
@Test
void preservesUnknownFieldsWhenSavingKnownPlayers() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
future-root: retained
players:
%s:
name: Alex
opted-in: false
locked: false
first-join: '2026-08-10T12:34:56Z'
future-player-field: retained
""".formatted(playerId));
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
repository.save(repository.load());
String saved = Files.readString(stateFile);
assertTrue(saved.contains("future-root: retained"));
assertTrue(saved.contains("future-player-field: retained"));
}
}