feat(config): add validated persistent state
Release / release (push) Failing after 12s
CI / build (push) Successful in 47s

This commit is contained in:
dmg
2026-08-08 13:15:21 -04:00
parent 152cac0740
commit e646d12863
19 changed files with 639 additions and 12 deletions
+11
View File
@@ -17,6 +17,17 @@ The behavior under development is specified in the [OKF design bundle](design/in
The plugin JAR is written to `build/libs/`.
On first startup, the plugin creates `plugins/TriggerSpawn/config.yml`. Its cooldown defaults are:
```yaml
cooldowns:
one-kill-seconds: 28800
two-kill-seconds: 14400
three-kill-seconds: 3600
```
Durable player and per-world state is stored in `plugins/TriggerSpawn/state.yml`.
For a local versioned build:
```bash
+7
View File
@@ -26,3 +26,10 @@ description: Chronological record of significant Trigger Spawn design decisions.
- Added the Java 17 Gradle project targeting Spigot API 26.2, with JUnit 5 verification and versioned resource processing.
- Added Gitea CI and semantic-release workflows modeled on `spigot-event-producer`.
- Documented local builds, public releases, and the required `RELEASE_TOKEN` repository secret.
## 2026-08-08 — Configuration and persistence
- Added validated, independently configurable real-time cooldown tiers with documented defaults.
- Added atomic YAML persistence for UUID-based player access state and per-world spawn settings.
- Invalid persisted access values fail closed, while invalid required configuration disables the plugin rather than allowing partial startup.
- Added friendly duration formatting and the administrative permission declaration.
@@ -2,7 +2,7 @@
type: User Story
title: "US-006: Configure plugin behavior"
description: Let operators tune cooldown tiers and run the plugin with clear validation, messages, permissions, and durable state.
status: backlog
status: done
---
# US-006: Configure plugin behavior
@@ -11,17 +11,17 @@ As a **server operator**, I want safe configuration and durable state so that Tr
## Acceptance criteria
- [ ] The one-kill cooldown is independently configurable and defaults to 28,800 seconds.
- [ ] The two-kill cooldown is independently configurable and defaults to 14,400 seconds.
- [ ] The three-kill cooldown is independently configurable and defaults to 3,600 seconds.
- [ ] Configured cooldowns must be positive and representable safely by the plugin.
- [ ] Invalid required configuration prevents partial initialization and produces a clear server log message.
- [ ] Player-facing messages use clear built-in wording, readable colors, and friendly duration formatting.
- [ ] Ordinary players do not require a plugin permission node to execute `/spawn`; eligibility is controlled by progression, grants, cooldowns, and bans.
- [ ] Administrative commands require `triggerspawn.admin`.
- [ ] Server operators receive `triggerspawn.admin` by default.
- [ ] Boss progress, grants, bans, cooldown timestamps, latest known names, and per-world spawn settings persist across clean restarts.
- [ ] Persisted data is handled defensively so corrupt or invalid records do not silently grant access.
- [x] The one-kill cooldown is independently configurable and defaults to 28,800 seconds.
- [x] The two-kill cooldown is independently configurable and defaults to 14,400 seconds.
- [x] The three-kill cooldown is independently configurable and defaults to 3,600 seconds.
- [x] Configured cooldowns must be positive and representable safely by the plugin.
- [x] Invalid required configuration prevents partial initialization and produces a clear server log message.
- [x] Player-facing messages use clear built-in wording, readable colors, and friendly duration formatting.
- [x] Ordinary players do not require a plugin permission node to execute `/spawn`; eligibility is controlled by progression, grants, cooldowns, and bans.
- [x] Administrative commands require `triggerspawn.admin`.
- [x] Server operators receive `triggerspawn.admin` by default.
- [x] Boss progress, grants, bans, cooldown timestamps, latest known names, and per-world spawn settings persist across clean restarts.
- [x] Persisted data is handled defensively so corrupt or invalid records do not silently grant access.
## Related
@@ -0,0 +1,7 @@
package games.dmg.triggerspawn;
enum BossType {
WARDEN,
ENDER_DRAGON,
WITHER
}
@@ -0,0 +1,32 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
final class DurationFormatter {
private DurationFormatter() {
}
static String format(Duration duration) {
long seconds = Math.max(0, duration.getSeconds());
long days = seconds / 86_400;
long hours = seconds % 86_400 / 3_600;
long minutes = seconds % 3_600 / 60;
long remainder = seconds % 60;
List<String> parts = new ArrayList<>();
if (days > 0) {
parts.add(days + "d");
}
if (hours > 0) {
parts.add(hours + "h");
}
if (minutes > 0) {
parts.add(minutes + "m");
}
if (remainder > 0 || parts.isEmpty()) {
parts.add(remainder + "s");
}
return String.join(" ", parts);
}
}
@@ -0,0 +1,15 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import org.bukkit.ChatColor;
final class Messages {
private Messages() {
}
static String cooldownBlocked(Duration remaining) {
return ChatColor.RED + "You cannot use /spawn yet. "
+ ChatColor.YELLOW + "Cooldown remaining: "
+ ChatColor.WHITE + DurationFormatter.format(remaining) + ".";
}
}
@@ -0,0 +1,17 @@
package games.dmg.triggerspawn;
import java.util.Map;
import java.util.UUID;
record PersistentState(
Map<UUID, PlayerState> players,
Map<UUID, WorldSpawnState> worlds) {
PersistentState {
players = Map.copyOf(players);
worlds = Map.copyOf(worlds);
}
static PersistentState empty() {
return new PersistentState(Map.of(), Map.of());
}
}
@@ -0,0 +1,39 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
record PlayerState(
UUID playerId,
String latestName,
Set<BossType> defeatedBosses,
Optional<Duration> grantedCooldown,
boolean banned,
Optional<Instant> lastSpawnUse) {
PlayerState {
if (playerId == null) {
throw new IllegalArgumentException("player ID is required");
}
if (latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("latest player name is required");
}
defeatedBosses = Set.copyOf(defeatedBosses);
grantedCooldown = grantedCooldown == null ? Optional.empty() : grantedCooldown;
lastSpawnUse = lastSpawnUse == null ? Optional.empty() : lastSpawnUse;
if (grantedCooldown.isPresent() && (grantedCooldown.get().isZero()
|| grantedCooldown.get().isNegative())) {
throw new IllegalArgumentException("granted cooldown must be positive");
}
if (banned && (!defeatedBosses.isEmpty() || grantedCooldown.isPresent())) {
throw new IllegalArgumentException("banned players cannot retain access");
}
}
static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId, latestName, Set.of(), Optional.empty(), false, Optional.empty());
}
}
@@ -0,0 +1,39 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import org.bukkit.configuration.ConfigurationSection;
record PluginSettings(
Duration oneKillCooldown,
Duration twoKillCooldown,
Duration threeKillCooldown) {
private static final long DEFAULT_ONE_KILL_SECONDS = 28_800;
private static final long DEFAULT_TWO_KILL_SECONDS = 14_400;
private static final long DEFAULT_THREE_KILL_SECONDS = 3_600;
static PluginSettings from(ConfigurationSection configuration) {
return new PluginSettings(
readDuration(configuration, "cooldowns.one-kill-seconds", DEFAULT_ONE_KILL_SECONDS),
readDuration(configuration, "cooldowns.two-kill-seconds", DEFAULT_TWO_KILL_SECONDS),
readDuration(configuration, "cooldowns.three-kill-seconds", DEFAULT_THREE_KILL_SECONDS));
}
private static Duration readDuration(
ConfigurationSection configuration, String path, long defaultSeconds) {
Object configured = configuration.get(path);
if (configured != null && !(configured instanceof Number)) {
throw new IllegalArgumentException(path + " must be a number of seconds");
}
long seconds = defaultSeconds;
if (configured instanceof Number number) {
seconds = number.longValue();
if (!Double.isFinite(number.doubleValue()) || number.doubleValue() != (double) seconds) {
throw new IllegalArgumentException(path + " must be a whole number of seconds");
}
}
if (seconds <= 0) {
throw new IllegalArgumentException(path + " must be a positive number of seconds");
}
return Duration.ofSeconds(seconds);
}
}
@@ -0,0 +1,10 @@
package games.dmg.triggerspawn;
record SpawnLocation(double x, double y, double z, float yaw, float pitch) {
SpawnLocation {
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("spawn location values must be finite");
}
}
}
@@ -0,0 +1,55 @@
package games.dmg.triggerspawn;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
final class SpawnStateManager {
private final YamlSpawnStateRepository repository;
private PersistentState state;
private SpawnStateManager(YamlSpawnStateRepository repository, PersistentState state) {
this.repository = repository;
this.state = state;
}
static SpawnStateManager load(YamlSpawnStateRepository repository) throws IOException {
return new SpawnStateManager(repository, repository.load());
}
PersistentState snapshot() {
return state;
}
PlayerState player(UUID playerId, String latestName) {
PlayerState existing = state.players().get(playerId);
return existing == null ? PlayerState.newPlayer(playerId, latestName) : existing;
}
WorldSpawnState world(UUID worldId, String latestName) {
WorldSpawnState existing = state.worlds().get(worldId);
return existing == null ? WorldSpawnState.newWorld(worldId, latestName) : existing;
}
void putPlayer(PlayerState player) throws IOException {
Map<UUID, PlayerState> players = new HashMap<>(state.players());
players.put(player.playerId(), player);
replace(new PersistentState(players, state.worlds()));
}
void putWorld(WorldSpawnState world) throws IOException {
Map<UUID, WorldSpawnState> worlds = new HashMap<>(state.worlds());
worlds.put(world.worldId(), world);
replace(new PersistentState(state.players(), worlds));
}
void save() throws IOException {
repository.save(state);
}
private void replace(PersistentState replacement) throws IOException {
repository.save(replacement);
state = replacement;
}
}
@@ -1,7 +1,45 @@
package games.dmg.triggerspawn;
import java.io.IOException;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Entry point for the Trigger Spawn Spigot plugin. */
public final class TriggerSpawnPlugin extends JavaPlugin {
private PluginSettings settings;
private SpawnStateManager stateManager;
@Override
public void onEnable() {
saveDefaultConfig();
try {
settings = PluginSettings.from(getConfig());
YamlSpawnStateRepository repository =
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
stateManager = SpawnStateManager.load(repository);
} catch (IllegalArgumentException | IOException exception) {
getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception);
getServer().getPluginManager().disablePlugin(this);
}
}
@Override
public void onDisable() {
if (stateManager == null) {
return;
}
try {
stateManager.save();
} catch (IOException exception) {
getLogger().log(Level.SEVERE, "Could not persist Trigger Spawn state", exception);
}
}
PluginSettings settings() {
return settings;
}
SpawnStateManager stateManager() {
return stateManager;
}
}
@@ -0,0 +1,30 @@
package games.dmg.triggerspawn;
import java.util.Optional;
import java.util.UUID;
record WorldSpawnState(
UUID worldId,
String latestName,
Optional<SpawnLocation> customCenter,
int maxDistance) {
static final int DEFAULT_MAX_DISTANCE = 20;
WorldSpawnState {
if (worldId == null) {
throw new IllegalArgumentException("world ID is required");
}
if (latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("latest world name is required");
}
customCenter = customCenter == null ? Optional.empty() : customCenter;
if (maxDistance < 0) {
throw new IllegalArgumentException("maximum spawn distance cannot be negative");
}
}
static WorldSpawnState newWorld(UUID worldId, String latestName) {
return new WorldSpawnState(
worldId, latestName, Optional.empty(), DEFAULT_MAX_DISTANCE);
}
}
@@ -0,0 +1,189 @@
package games.dmg.triggerspawn;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
final class YamlSpawnStateRepository {
private final Path stateFile;
YamlSpawnStateRepository(Path stateFile) {
this.stateFile = stateFile;
}
PersistentState load() throws IOException {
if (!Files.exists(stateFile)) {
return PersistentState.empty();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("state file is not valid YAML", exception);
}
return new PersistentState(loadPlayers(yaml), loadWorlds(yaml));
}
void save(PersistentState state) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
savePlayers(yaml, state.players());
saveWorlds(yaml, state.worlds());
Path temporary = Files.createTempFile(parent, "trigger-spawn-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 Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerState> players = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection("players");
if (section == null) {
return players;
}
for (String key : section.getKeys(false)) {
try {
UUID id = UUID.fromString(key);
String path = "players." + key;
String name = yaml.getString(path + ".name");
if (name == null || name.isBlank()) {
continue;
}
boolean banned = yaml.getBoolean(path + ".banned", false);
EnumSet<BossType> defeated = EnumSet.noneOf(BossType.class);
if (!banned) {
for (String value : yaml.getStringList(path + ".defeated")) {
try {
defeated.add(BossType.valueOf(value));
} catch (IllegalArgumentException ignored) {
// Unknown future or corrupt values grant no access.
}
}
}
Optional<Duration> grant = Optional.empty();
Object configuredGrant = yaml.get(path + ".grant-cooldown-seconds");
if (!banned && configuredGrant instanceof Number number) {
long seconds = number.longValue();
if (seconds > 0) {
grant = Optional.of(Duration.ofSeconds(seconds));
}
}
Optional<Instant> lastUse = Optional.empty();
if (yaml.isLong(path + ".last-spawn-epoch-millis")) {
long millis = yaml.getLong(path + ".last-spawn-epoch-millis");
if (millis >= 0) {
lastUse = Optional.of(Instant.ofEpochMilli(millis));
}
}
players.put(id, new PlayerState(id, name, defeated, grant, banned, lastUse));
} catch (IllegalArgumentException ignored) {
// Invalid records are ignored rather than granting access.
}
}
return players;
}
private static Map<UUID, WorldSpawnState> loadWorlds(YamlConfiguration yaml) {
Map<UUID, WorldSpawnState> worlds = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection("worlds");
if (section == null) {
return worlds;
}
for (String key : section.getKeys(false)) {
try {
UUID id = UUID.fromString(key);
String path = "worlds." + key;
String name = yaml.getString(path + ".name");
int maxDistance = yaml.getInt(path + ".max-distance", WorldSpawnState.DEFAULT_MAX_DISTANCE);
if (name == null || name.isBlank() || maxDistance < 0) {
continue;
}
Optional<SpawnLocation> center = loadCenter(yaml, path + ".center");
worlds.put(id, new WorldSpawnState(id, name, center, maxDistance));
} catch (IllegalArgumentException ignored) {
// Invalid records are ignored.
}
}
return worlds;
}
private static Optional<SpawnLocation> loadCenter(YamlConfiguration yaml, String path) {
if (!yaml.isConfigurationSection(path)) {
return Optional.empty();
}
List<String> fields = List.of("x", "y", "z", "yaw", "pitch");
if (fields.stream().anyMatch(field -> !yaml.isDouble(path + "." + field))) {
return Optional.empty();
}
try {
return Optional.of(new SpawnLocation(
yaml.getDouble(path + ".x"),
yaml.getDouble(path + ".y"),
yaml.getDouble(path + ".z"),
(float) yaml.getDouble(path + ".yaw"),
(float) yaml.getDouble(path + ".pitch")));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}
private static void savePlayers(YamlConfiguration yaml, Map<UUID, PlayerState> players) {
for (PlayerState player : players.values()) {
String path = "players." + player.playerId();
yaml.set(path + ".name", player.latestName());
List<String> defeated = new ArrayList<>();
player.defeatedBosses().stream().map(Enum::name).sorted().forEach(defeated::add);
yaml.set(path + ".defeated", defeated);
yaml.set(path + ".banned", player.banned());
yaml.set(
path + ".grant-cooldown-seconds",
player.grantedCooldown().map(Duration::getSeconds).orElse(null));
yaml.set(
path + ".last-spawn-epoch-millis",
player.lastSpawnUse().map(Instant::toEpochMilli).orElse(null));
}
}
private static void saveWorlds(YamlConfiguration yaml, Map<UUID, WorldSpawnState> worlds) {
for (WorldSpawnState world : worlds.values()) {
String path = "worlds." + world.worldId();
yaml.set(path + ".name", world.latestName());
yaml.set(path + ".max-distance", world.maxDistance());
world.customCenter().ifPresent(center -> {
yaml.set(path + ".center.x", center.x());
yaml.set(path + ".center.y", center.y());
yaml.set(path + ".center.z", center.z());
yaml.set(path + ".center.yaw", center.yaw());
yaml.set(path + ".center.pitch", center.pitch());
});
}
}
}
+5
View File
@@ -0,0 +1,5 @@
# Real-time cooldowns for the number of unique qualifying bosses defeated.
cooldowns:
one-kill-seconds: 28800
two-kill-seconds: 14400
three-kill-seconds: 3600
@@ -0,0 +1,15 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import org.junit.jupiter.api.Test;
final class DurationFormatterTest {
@Test
void formatsCooldownsForPlayers() {
assertEquals("8h", DurationFormatter.format(Duration.ofHours(8)));
assertEquals("1h 1m 1s", DurationFormatter.format(Duration.ofSeconds(3_661)));
assertEquals("30m", DurationFormatter.format(Duration.ofMinutes(30)));
}
}
@@ -0,0 +1,18 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import org.bukkit.ChatColor;
import org.junit.jupiter.api.Test;
final class MessagesTest {
@Test
void cooldownDenialIsColoredAndShowsFriendlyRemainingTime() {
String message = Messages.cooldownBlocked(Duration.ofMinutes(90));
assertTrue(message.startsWith(ChatColor.RED.toString()));
assertTrue(message.contains("cannot use /spawn yet"));
assertTrue(message.contains("1h 30m"));
}
}
@@ -0,0 +1,35 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import org.bukkit.configuration.MemoryConfiguration;
import org.junit.jupiter.api.Test;
final class PluginSettingsTest {
@Test
void suppliesDocumentedCooldownDefaults() {
PluginSettings settings = PluginSettings.from(new MemoryConfiguration());
assertEquals(Duration.ofHours(8), settings.oneKillCooldown());
assertEquals(Duration.ofHours(4), settings.twoKillCooldown());
assertEquals(Duration.ofHours(1), settings.threeKillCooldown());
}
@Test
void rejectsMalformedConfiguredCooldown() {
MemoryConfiguration configuration = new MemoryConfiguration();
configuration.set("cooldowns.one-kill-seconds", "eventually");
assertThrows(IllegalArgumentException.class, () -> PluginSettings.from(configuration));
}
@Test
void rejectsFractionalConfiguredCooldown() {
MemoryConfiguration configuration = new MemoryConfiguration();
configuration.set("cooldowns.two-kill-seconds", 1.5D);
assertThrows(IllegalArgumentException.class, () -> PluginSettings.from(configuration));
}
}
@@ -0,0 +1,65 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlSpawnStateRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsPlayerAndWorldState() throws Exception {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState player = new PlayerState(
playerId,
"Alex",
Set.of(BossType.WARDEN, BossType.WITHER),
Optional.of(Duration.ofMinutes(30)),
false,
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)));
WorldSpawnState world = new WorldSpawnState(
worldId,
"overworld",
Optional.of(new SpawnLocation(12.5, 70.0, -8.5, 90.0F, 10.0F)),
20);
PersistentState expected = new PersistentState(
Map.of(playerId, player), Map.of(worldId, world));
YamlSpawnStateRepository repository =
new YamlSpawnStateRepository(temporaryDirectory.resolve("state.yml"));
repository.save(expected);
assertEquals(expected, repository.load());
}
@Test
void corruptAccessValuesNeverGrantAccess() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
defeated: [NOT_A_BOSS]
grant-cooldown-seconds: free
banned: false
""".formatted(playerId));
PlayerState loaded = new YamlSpawnStateRepository(stateFile).load().players().get(playerId);
assertTrue(loaded.defeatedBosses().isEmpty());
assertTrue(loaded.grantedCooldown().isEmpty());
}
}