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/`.
|
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:
|
For a local versioned build:
|
||||||
|
|
||||||
```bash
|
```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 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`.
|
- Added Gitea CI and semantic-release workflows modeled on `spigot-event-producer`.
|
||||||
- Documented local builds, public releases, and the required `RELEASE_TOKEN` repository secret.
|
- 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
|
type: User Story
|
||||||
title: "US-006: Configure plugin behavior"
|
title: "US-006: Configure plugin behavior"
|
||||||
description: Let operators tune cooldown tiers and run the plugin with clear validation, messages, permissions, and durable state.
|
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
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The one-kill cooldown is independently configurable and defaults to 28,800 seconds.
|
- [x] 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.
|
- [x] 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.
|
- [x] The three-kill cooldown is independently configurable and defaults to 3,600 seconds.
|
||||||
- [ ] Configured cooldowns must be positive and representable safely by the plugin.
|
- [x] Configured cooldowns must be positive and representable safely by the plugin.
|
||||||
- [ ] Invalid required configuration prevents partial initialization and produces a clear server log message.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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`.
|
- [x] Administrative commands require `triggerspawn.admin`.
|
||||||
- [ ] Server operators receive `triggerspawn.admin` by default.
|
- [x] 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.
|
- [x] 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] Persisted data is handled defensively so corrupt or invalid records do not silently grant access.
|
||||||
|
|
||||||
## Related
|
## 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;
|
package games.dmg.triggerspawn;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.logging.Level;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
/** Entry point for the Trigger Spawn Spigot plugin. */
|
/** Entry point for the Trigger Spawn Spigot plugin. */
|
||||||
public final class TriggerSpawnPlugin extends JavaPlugin {
|
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