diff --git a/design/log.md b/design/log.md index d323514..30d9b99 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-006-configure-and-persist-leaf.md b/design/user-stories/us-006-configure-and-persist-leaf.md index 9c48bb7..f265a82 100644 --- a/design/user-stories/us-006-configure-and-persist-leaf.md +++ b/design/user-stories/us-006-configure-and-persist-leaf.md @@ -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 diff --git a/src/main/java/games/dmg/leaf/LeafPersistentState.java b/src/main/java/games/dmg/leaf/LeafPersistentState.java new file mode 100644 index 0000000..fd9ef30 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafPersistentState.java @@ -0,0 +1,14 @@ +package games.dmg.leaf; + +import java.util.Map; +import java.util.UUID; + +public record LeafPersistentState(Map players) { + public LeafPersistentState { + players = players == null ? Map.of() : Map.copyOf(players); + } + + public static LeafPersistentState empty() { + return new LeafPersistentState(Map.of()); + } +} diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index 0e8ab41..28c357e 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -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 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); + } + } } diff --git a/src/main/java/games/dmg/leaf/LeafSettings.java b/src/main/java/games/dmg/leaf/LeafSettings.java new file mode 100644 index 0000000..3ed6fef --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafSettings.java @@ -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 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 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 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 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; + } +} diff --git a/src/main/java/games/dmg/leaf/LeafSettingsProvider.java b/src/main/java/games/dmg/leaf/LeafSettingsProvider.java new file mode 100644 index 0000000..c1f6df2 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafSettingsProvider.java @@ -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"); + } +} diff --git a/src/main/java/games/dmg/leaf/LeafStateManager.java b/src/main/java/games/dmg/leaf/LeafStateManager.java new file mode 100644 index 0000000..42e544e --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafStateManager.java @@ -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 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 find(UUID playerId) { + return Optional.ofNullable(players.get(playerId)); + } + + public synchronized Map players() { + return Map.copyOf(players); + } + + public synchronized PlayerLeafState update( + UUID playerId, + UnaryOperator 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; + } +} diff --git a/src/main/java/games/dmg/leaf/PlayerLeafState.java b/src/main/java/games/dmg/leaf/PlayerLeafState.java new file mode 100644 index 0000000..d969a82 --- /dev/null +++ b/src/main/java/games/dmg/leaf/PlayerLeafState.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java b/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java new file mode 100644 index 0000000..2b8be82 --- /dev/null +++ b/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java @@ -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 loadPlayers(YamlConfiguration yaml) { + Map 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 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 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()); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..34f27d9 --- /dev/null +++ b/src/main/resources/config.yml @@ -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." diff --git a/src/test/java/games/dmg/leaf/LeafSettingsTest.java b/src/test/java/games/dmg/leaf/LeafSettingsTest.java new file mode 100644 index 0000000..ac45964 --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafSettingsTest.java @@ -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)) + ); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafStateManagerTest.java b/src/test/java/games/dmg/leaf/LeafStateManagerTest.java new file mode 100644 index 0000000..2dfeb39 --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafStateManagerTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/leaf/YamlLeafStateRepositoryTest.java b/src/test/java/games/dmg/leaf/YamlLeafStateRepositoryTest.java new file mode 100644 index 0000000..47ca2e2 --- /dev/null +++ b/src/test/java/games/dmg/leaf/YamlLeafStateRepositoryTest.java @@ -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")); + } +}