feat(config): add validated persistent state
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user