feat(config): add validated durable Leaf state
This commit is contained in:
@@ -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.
|
- 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`.
|
- 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.
|
- 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
|
type: User Story
|
||||||
title: "US-006: Configure and persist Leaf"
|
title: "US-006: Configure and persist Leaf"
|
||||||
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
|
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
|
||||||
status: backlog
|
status: in-progress
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-006: Configure and persist Leaf
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
|
- [x] 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] 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.
|
- [ ] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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] 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.
|
- [ ] 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
|
## 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;
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Level;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
public final class LeafPlugin extends JavaPlugin {
|
public final class LeafPlugin extends JavaPlugin {
|
||||||
|
private LeafSettingsProvider settingsProvider;
|
||||||
|
private LeafStateManager stateManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
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.");
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user