diff --git a/README.md b/README.md index 00c8ed7..b46522f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/design/log.md b/design/log.md index acbcf67..5c19e38 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-006-configure-plugin-behavior.md b/design/user-stories/us-006-configure-plugin-behavior.md index 0a87f2b..ba1b389 100644 --- a/design/user-stories/us-006-configure-plugin-behavior.md +++ b/design/user-stories/us-006-configure-plugin-behavior.md @@ -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 diff --git a/src/main/java/games/dmg/triggerspawn/BossType.java b/src/main/java/games/dmg/triggerspawn/BossType.java new file mode 100644 index 0000000..105cf6d --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/BossType.java @@ -0,0 +1,7 @@ +package games.dmg.triggerspawn; + +enum BossType { + WARDEN, + ENDER_DRAGON, + WITHER +} diff --git a/src/main/java/games/dmg/triggerspawn/DurationFormatter.java b/src/main/java/games/dmg/triggerspawn/DurationFormatter.java new file mode 100644 index 0000000..8ec7f73 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/DurationFormatter.java @@ -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 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); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/Messages.java b/src/main/java/games/dmg/triggerspawn/Messages.java new file mode 100644 index 0000000..c240c60 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/Messages.java @@ -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) + "."; + } +} diff --git a/src/main/java/games/dmg/triggerspawn/PersistentState.java b/src/main/java/games/dmg/triggerspawn/PersistentState.java new file mode 100644 index 0000000..da1d250 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/PersistentState.java @@ -0,0 +1,17 @@ +package games.dmg.triggerspawn; + +import java.util.Map; +import java.util.UUID; + +record PersistentState( + Map players, + Map worlds) { + PersistentState { + players = Map.copyOf(players); + worlds = Map.copyOf(worlds); + } + + static PersistentState empty() { + return new PersistentState(Map.of(), Map.of()); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/PlayerState.java b/src/main/java/games/dmg/triggerspawn/PlayerState.java new file mode 100644 index 0000000..e304875 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/PlayerState.java @@ -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 defeatedBosses, + Optional grantedCooldown, + boolean banned, + Optional 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()); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/PluginSettings.java b/src/main/java/games/dmg/triggerspawn/PluginSettings.java new file mode 100644 index 0000000..ec82a42 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/PluginSettings.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnLocation.java b/src/main/java/games/dmg/triggerspawn/SpawnLocation.java new file mode 100644 index 0000000..0f89565 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnLocation.java @@ -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"); + } + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnStateManager.java b/src/main/java/games/dmg/triggerspawn/SpawnStateManager.java new file mode 100644 index 0000000..a882941 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnStateManager.java @@ -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 players = new HashMap<>(state.players()); + players.put(player.playerId(), player); + replace(new PersistentState(players, state.worlds())); + } + + void putWorld(WorldSpawnState world) throws IOException { + Map 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; + } +} diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index 1a4c7cd..e001397 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -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; + } } diff --git a/src/main/java/games/dmg/triggerspawn/WorldSpawnState.java b/src/main/java/games/dmg/triggerspawn/WorldSpawnState.java new file mode 100644 index 0000000..9d3db89 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/WorldSpawnState.java @@ -0,0 +1,30 @@ +package games.dmg.triggerspawn; + +import java.util.Optional; +import java.util.UUID; + +record WorldSpawnState( + UUID worldId, + String latestName, + Optional 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); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/YamlSpawnStateRepository.java b/src/main/java/games/dmg/triggerspawn/YamlSpawnStateRepository.java new file mode 100644 index 0000000..9756fe2 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/YamlSpawnStateRepository.java @@ -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 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 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 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 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 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 loadWorlds(YamlConfiguration yaml) { + Map 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 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 loadCenter(YamlConfiguration yaml, String path) { + if (!yaml.isConfigurationSection(path)) { + return Optional.empty(); + } + List 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 players) { + for (PlayerState player : players.values()) { + String path = "players." + player.playerId(); + yaml.set(path + ".name", player.latestName()); + List 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 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()); + }); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..a9f678b --- /dev/null +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/test/java/games/dmg/triggerspawn/DurationFormatterTest.java b/src/test/java/games/dmg/triggerspawn/DurationFormatterTest.java new file mode 100644 index 0000000..68c53c5 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/DurationFormatterTest.java @@ -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))); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/MessagesTest.java b/src/test/java/games/dmg/triggerspawn/MessagesTest.java new file mode 100644 index 0000000..a1656e7 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/MessagesTest.java @@ -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")); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/PluginSettingsTest.java b/src/test/java/games/dmg/triggerspawn/PluginSettingsTest.java new file mode 100644 index 0000000..14335f2 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/PluginSettingsTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/YamlSpawnStateRepositoryTest.java b/src/test/java/games/dmg/triggerspawn/YamlSpawnStateRepositoryTest.java new file mode 100644 index 0000000..19495da --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/YamlSpawnStateRepositoryTest.java @@ -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()); + } +}