diff --git a/design/log.md b/design/log.md index 4215634..3503ba0 100644 --- a/design/log.md +++ b/design/log.md @@ -19,3 +19,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear - Extended US-001 so a self-destructing creeper awards deduplicated progress and normal feedback to every player its explosion hits. - Completed US-004 with an asynchronous self-service progress command, ordinary-player permission, completion output, and command metadata. - Completed US-005 with UUID-backed offline inspection, atomic progress and rank administration, separate permissions, online-state refresh, and audit logging. +- Completed US-006 with validated per-rank requirements and multipliers, configurable feedback, persistent threshold administration, and safe reload behavior. diff --git a/design/user-stories/us-006-configure-aura-progression.md b/design/user-stories/us-006-configure-aura-progression.md index b0e9456..3a65d60 100644 --- a/design/user-stories/us-006-configure-aura-progression.md +++ b/design/user-stories/us-006-configure-aura-progression.md @@ -2,7 +2,7 @@ type: User Story title: "US-006: Configure aura progression" description: Let administrators safely configure and persist per-rank requirements, multipliers, and feedback. -status: backlog +status: done --- # US-006: Configure aura progression @@ -11,18 +11,18 @@ As a **server administrator**, I want to configure progression and aura behavior ## Acceptance criteria -- [ ] Configuration provides documented defaults for the six per-rank kill requirements and six unlocked-rank damage multipliers. -- [ ] Rank requirements are positive integers. -- [ ] Damage multipliers are finite and non-negative. -- [ ] Progress boss-bar duration and player-facing rank messages are configurable. -- [ ] `/creeperaura threshold ` validates, applies, and persists the points required to unlock that rank. -- [ ] `/creeperaura reload` safely loads externally edited configuration without requiring a server restart. -- [ ] Threshold-management and reload commands require documented administrative permissions. -- [ ] Invalid configuration is rejected with actionable diagnostics while the last valid configuration remains active. -- [ ] Changing requirements never automatically removes an unlocked rank, grants a new rank, or discards current-tier progress. -- [ ] After a requirement change, the player's next qualifying kill can grant at most the next rank when its requirement is satisfied. -- [ ] Rank advancement resets current-tier progress to zero rather than carrying excess progress forward. -- [ ] Configuration-command changes survive plugin and server restarts. +- [x] Configuration provides documented defaults for the six per-rank kill requirements and six unlocked-rank damage multipliers. +- [x] Rank requirements are positive integers. +- [x] Damage multipliers are finite and non-negative. +- [x] Progress boss-bar duration and player-facing rank messages are configurable. +- [x] `/creeperaura threshold ` validates, applies, and persists the points required to unlock that rank. +- [x] `/creeperaura reload` safely loads externally edited configuration without requiring a server restart. +- [x] Threshold-management and reload commands require documented administrative permissions. +- [x] Invalid configuration is rejected with actionable diagnostics while the last valid configuration remains active. +- [x] Changing requirements never automatically removes an unlocked rank, grants a new rank, or discards current-tier progress. +- [x] After a requirement change, the player's next qualifying kill can grant at most the next rank when its requirement is satisfied. +- [x] Rank advancement resets current-tier progress to zero rather than carrying excess progress forward. +- [x] Configuration-command changes survive plugin and server restarts. ## Related diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java index 4ce65ba..06da45e 100644 --- a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -5,6 +5,8 @@ import games.dmg.creeperfear.aura.CreeperAuraListener; import games.dmg.creeperfear.command.CreeperAuraCommand; import games.dmg.creeperfear.command.ProgressMessages; import games.dmg.creeperfear.command.ProgressMutations; +import games.dmg.creeperfear.config.AuraConfigLoader; +import games.dmg.creeperfear.config.AuraConfigurationManager; import games.dmg.creeperfear.feedback.ProgressDisplay; import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.listener.CreeperDeathListener; @@ -28,8 +30,10 @@ public final class CreeperFearPlugin extends JavaPlugin { Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); AuraRules auraRules = AuraRules.defaults(); progressService = new ProgressService(new SqliteProgressRepository(databasePath), auraRules); - long displayTicks = Math.max(1L, getConfig().getLong("feedback.progress-bar-seconds", 5L)) * 20L; - progressFeedback = new ProgressFeedback(this, new ProgressDisplay(auraRules), displayTicks); + progressFeedback = new ProgressFeedback(this, new ProgressDisplay(auraRules), 100L); + AuraConfigurationManager configurationManager = new AuraConfigurationManager( + this, new AuraConfigLoader(), auraRules, progressFeedback); + configurationManager.applyCurrent(); getServer().getPluginManager().registerEvents( new CreeperDeathListener(progressService, progressFeedback, getLogger()), this); getServer().getPluginManager().registerEvents( @@ -44,7 +48,8 @@ public final class CreeperFearPlugin extends JavaPlugin { progressService, new ProgressMessages(auraRules), new ProgressMutations(), - progressFeedback)); + progressFeedback, + configurationManager)); getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId()) .exceptionally(failure -> { getLogger().log(Level.SEVERE, diff --git a/src/main/java/games/dmg/creeperfear/aura/AuraRules.java b/src/main/java/games/dmg/creeperfear/aura/AuraRules.java index 1fb2760..c981934 100644 --- a/src/main/java/games/dmg/creeperfear/aura/AuraRules.java +++ b/src/main/java/games/dmg/creeperfear/aura/AuraRules.java @@ -2,24 +2,22 @@ package games.dmg.creeperfear.aura; import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.PlayerProgress; +import java.util.Collections; import java.util.EnumMap; import java.util.Map; public final class AuraRules { - private final Map requirements; - private final Map damageMultipliers; + private volatile Map requirements; + private volatile Map damageMultipliers; public AuraRules(Map requirements, Map damageMultipliers) { - this.requirements = new EnumMap<>(requirements); - this.damageMultipliers = new EnumMap<>(damageMultipliers); + replace(requirements, damageMultipliers); } public static AuraRules defaults() { Map requirements = new EnumMap<>(AuraRank.class); for (AuraRank rank : AuraRank.values()) { - if (!rank.isMaximum()) { - requirements.put(rank, 100); - } + if (!rank.isMaximum()) requirements.put(rank, 100); } Map multipliers = new EnumMap<>(AuraRank.class); multipliers.put(AuraRank.LOCKED, 1.0); @@ -32,16 +30,36 @@ public final class AuraRules { return new AuraRules(requirements, multipliers); } + public synchronized void replaceWith(AuraRules replacement) { + replace(replacement.requirements, replacement.damageMultipliers); + } + + private void replace(Map newRequirements, Map newMultipliers) { + EnumMap checkedRequirements = new EnumMap<>(AuraRank.class); + for (AuraRank rank : AuraRank.values()) { + if (rank.isMaximum()) continue; + Integer value = newRequirements.get(rank); + if (value == null || value <= 0) { + throw new IllegalArgumentException("Requirement for " + rank.next() + " must be a positive integer."); + } + checkedRequirements.put(rank, value); + } + EnumMap checkedMultipliers = new EnumMap<>(AuraRank.class); + for (AuraRank rank : AuraRank.values()) { + Double value = newMultipliers.get(rank); + if (value == null || !Double.isFinite(value) || value < 0) { + throw new IllegalArgumentException("Damage multiplier for " + rank + " must be finite and non-negative."); + } + checkedMultipliers.put(rank, value); + } + requirements = Collections.unmodifiableMap(checkedRequirements); + damageMultipliers = Collections.unmodifiableMap(checkedMultipliers); + } + public PlayerProgress advanceIfEarned(PlayerProgress progress) { - if (progress.rank().isMaximum()) { - return progress; - } - int required = requirements.get(progress.rank()); - if (progress.tierKills() < required) { - return progress; - } - return new PlayerProgress( - progress.playerId(), progress.lastKnownName(), progress.rank().next(), 0); + if (progress.rank().isMaximum()) return progress; + if (progress.tierKills() < requirementForCurrentRank(progress.rank())) return progress; + return new PlayerProgress(progress.playerId(), progress.lastKnownName(), progress.rank().next(), 0); } public double damageMultiplier(AuraRank rank) { @@ -49,9 +67,7 @@ public final class AuraRules { } public int requirementForCurrentRank(AuraRank rank) { - if (rank.isMaximum()) { - throw new IllegalArgumentException("Rank VI has no next requirement"); - } + if (rank.isMaximum()) throw new IllegalArgumentException("Rank VI has no next requirement"); return requirements.get(rank); } } diff --git a/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java b/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java index 171bc15..79f655e 100644 --- a/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java +++ b/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java @@ -1,5 +1,6 @@ package games.dmg.creeperfear.command; +import games.dmg.creeperfear.config.AuraConfigurationManager; import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.PlayerProgress; @@ -22,22 +23,47 @@ public final class CreeperAuraCommand implements CommandExecutor { private final ProgressMessages messages; private final ProgressMutations mutations; private final ProgressFeedback feedback; + private final AuraConfigurationManager configurationManager; public CreeperAuraCommand( JavaPlugin plugin, ProgressService progressService, ProgressMessages messages, ProgressMutations mutations, - ProgressFeedback feedback) { + ProgressFeedback feedback, + AuraConfigurationManager configurationManager) { this.plugin = plugin; this.progressService = progressService; this.messages = messages; this.mutations = mutations; this.feedback = feedback; + this.configurationManager = configurationManager; } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { + if (!require(sender, "creeperfear.admin.configure")) return true; + try { + configurationManager.reload(); + sender.sendMessage(ChatColor.GREEN + "Creeper Aura configuration reloaded."); + } catch (RuntimeException exception) { + sender.sendMessage(ChatColor.RED + "Configuration rejected: " + exception.getMessage()); + } + return true; + } + if (args.length == 3 && args[0].equalsIgnoreCase("threshold")) { + if (!require(sender, "creeperfear.admin.configure")) return true; + try { + AuraRank rank = AuraRank.valueOf(args[1].toUpperCase()); + int points = Integer.parseInt(args[2]); + configurationManager.setThreshold(rank, points); + sender.sendMessage(ChatColor.GREEN + "Set the " + rank + " requirement to " + points + "."); + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + "Threshold rejected: " + exception.getMessage()); + } + return true; + } if (args.length == 1 && args[0].equalsIgnoreCase("progress")) { return showSelf(sender); } diff --git a/src/main/java/games/dmg/creeperfear/config/AuraConfigLoader.java b/src/main/java/games/dmg/creeperfear/config/AuraConfigLoader.java new file mode 100644 index 0000000..7503d6a --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/config/AuraConfigLoader.java @@ -0,0 +1,45 @@ +package games.dmg.creeperfear.config; + +import games.dmg.creeperfear.aura.AuraRules; +import games.dmg.creeperfear.progress.AuraRank; +import java.util.EnumMap; +import java.util.Map; +import org.bukkit.configuration.ConfigurationSection; + +public final class AuraConfigLoader { + public AuraConfiguration load(ConfigurationSection config) { + Map requirements = new EnumMap<>(AuraRank.class); + for (AuraRank current : AuraRank.values()) { + if (current.isMaximum()) continue; + AuraRank target = current.next(); + String path = "progression.requirements." + target.name(); + if (!config.isInt(path)) throw new IllegalArgumentException(path + " must be an integer."); + requirements.put(current, config.getInt(path)); + } + + Map multipliers = new EnumMap<>(AuraRank.class); + multipliers.put(AuraRank.LOCKED, 1.0); + for (AuraRank rank : AuraRank.values()) { + if (!rank.isUnlocked()) continue; + String path = "aura.damage-multipliers." + rank.name(); + if (!config.isDouble(path) && !config.isInt(path)) { + throw new IllegalArgumentException(path + " must be a number."); + } + multipliers.put(rank, config.getDouble(path)); + } + + int seconds = config.getInt("feedback.progress-bar-seconds"); + if (!config.isInt("feedback.progress-bar-seconds") || seconds <= 0) { + throw new IllegalArgumentException("feedback.progress-bar-seconds must be a positive integer."); + } + String title = requiredString(config, "feedback.rank-up-title"); + String subtitle = requiredString(config, "feedback.rank-up-subtitle"); + return new AuraConfiguration(new AuraRules(requirements, multipliers), seconds * 20L, title, subtitle); + } + + private String requiredString(ConfigurationSection config, String path) { + String value = config.getString(path); + if (value == null || value.isBlank()) throw new IllegalArgumentException(path + " must not be blank."); + return value; + } +} diff --git a/src/main/java/games/dmg/creeperfear/config/AuraConfiguration.java b/src/main/java/games/dmg/creeperfear/config/AuraConfiguration.java new file mode 100644 index 0000000..f50cbcc --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/config/AuraConfiguration.java @@ -0,0 +1,10 @@ +package games.dmg.creeperfear.config; + +import games.dmg.creeperfear.aura.AuraRules; + +public record AuraConfiguration( + AuraRules rules, + long progressBarTicks, + String rankUpTitle, + String rankUpSubtitle) { +} diff --git a/src/main/java/games/dmg/creeperfear/config/AuraConfigurationManager.java b/src/main/java/games/dmg/creeperfear/config/AuraConfigurationManager.java new file mode 100644 index 0000000..3669cdc --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/config/AuraConfigurationManager.java @@ -0,0 +1,54 @@ +package games.dmg.creeperfear.config; + +import games.dmg.creeperfear.aura.AuraRules; +import games.dmg.creeperfear.feedback.ProgressFeedback; +import games.dmg.creeperfear.progress.AuraRank; +import org.bukkit.plugin.java.JavaPlugin; + +public final class AuraConfigurationManager { + private final JavaPlugin plugin; + private final AuraConfigLoader loader; + private final AuraRules activeRules; + private final ProgressFeedback feedback; + + public AuraConfigurationManager( + JavaPlugin plugin, AuraConfigLoader loader, AuraRules activeRules, ProgressFeedback feedback) { + this.plugin = plugin; + this.loader = loader; + this.activeRules = activeRules; + this.feedback = feedback; + } + + public void applyCurrent() { + apply(loader.load(plugin.getConfig())); + } + + public void reload() { + plugin.reloadConfig(); + apply(loader.load(plugin.getConfig())); + } + + public void setThreshold(AuraRank targetRank, int points) { + if (!targetRank.isUnlocked()) throw new IllegalArgumentException("The target rank must be I through VI."); + if (points <= 0) throw new IllegalArgumentException("Threshold points must be positive."); + String path = "progression.requirements." + targetRank.name(); + Object prior = plugin.getConfig().get(path); + plugin.getConfig().set(path, points); + try { + AuraConfiguration candidate = loader.load(plugin.getConfig()); + plugin.saveConfig(); + apply(candidate); + } catch (RuntimeException exception) { + plugin.getConfig().set(path, prior); + throw exception; + } + } + + private void apply(AuraConfiguration configuration) { + activeRules.replaceWith(configuration.rules()); + feedback.reconfigure( + configuration.progressBarTicks(), + configuration.rankUpTitle(), + configuration.rankUpSubtitle()); + } +} diff --git a/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java b/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java index 8eaf7c7..06438bd 100644 --- a/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java +++ b/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java @@ -16,7 +16,9 @@ import org.bukkit.scheduler.BukkitTask; public final class ProgressFeedback implements KillFeedback, AutoCloseable { private final JavaPlugin plugin; private final ProgressDisplay display; - private final long displayTicks; + private volatile long displayTicks; + private volatile String rankUpTitle = "Creeper Aura %rank%"; + private volatile String rankUpSubtitle = "Unlocked!"; private final Map bars = new HashMap<>(); private final Map hideTasks = new HashMap<>(); @@ -32,8 +34,12 @@ public final class ProgressFeedback implements KillFeedback, AutoCloseable { } private void showOnServerThread(Player player, AuraRank previousRank, PlayerProgress progress) { - display.rankUpTitle(previousRank, progress.rank()).ifPresent(title -> - player.sendTitle(title, "Unlocked!", 10, 70, 20)); + display.rankUpTitle(previousRank, progress.rank()).ifPresent(ignored -> player.sendTitle( + rankUpTitle.replace("%rank%", progress.rank().name()), + rankUpSubtitle.replace("%rank%", progress.rank().name()), + 10, + 70, + 20)); ProgressDisplay.State state = display.forProgress(progress); if (!state.visible() || !player.isOnline()) { @@ -59,6 +65,13 @@ public final class ProgressFeedback implements KillFeedback, AutoCloseable { plugin, () -> hide(playerId), displayTicks)); } + public void reconfigure(long newDisplayTicks, String newRankUpTitle, String newRankUpSubtitle) { + if (newDisplayTicks <= 0) throw new IllegalArgumentException("Display duration must be positive."); + displayTicks = newDisplayTicks; + rankUpTitle = newRankUpTitle; + rankUpSubtitle = newRankUpSubtitle; + } + public void refreshIfVisible(Player player, PlayerProgress progress) { Bukkit.getScheduler().runTask(plugin, () -> { BossBar bar = bars.get(player.getUniqueId()); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index faccf9e..66eef95 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,2 +1,22 @@ +progression: + requirements: + I: 100 + II: 100 + III: 100 + IV: 100 + V: 100 + VI: 100 + +aura: + damage-multipliers: + I: 3.0 + II: 2.0 + III: 1.5 + IV: 1.0 + V: 0.5 + VI: 0.0 + feedback: progress-bar-seconds: 5 + rank-up-title: "Creeper Aura %rank%" + rank-up-subtitle: "Unlocked!" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 90df74c..5724645 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ description: Unlock Creeper Aura ranks by defeating creepers. commands: creeperaura: description: Check and administer Creeper Aura progression. - usage: / |add |rank > + usage: / |add |rank |threshold |reload> permissions: creeperfear.progress: description: Check personal Creeper Aura progression. @@ -18,3 +18,6 @@ permissions: creeperfear.admin.modify: description: Modify another player's Creeper Aura progression. default: op + creeperfear.admin.configure: + description: Change and reload Creeper Aura configuration. + default: op diff --git a/src/test/java/games/dmg/creeperfear/config/AuraConfigLoaderTest.java b/src/test/java/games/dmg/creeperfear/config/AuraConfigLoaderTest.java new file mode 100644 index 0000000..649caea --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/config/AuraConfigLoaderTest.java @@ -0,0 +1,43 @@ +package games.dmg.creeperfear.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import games.dmg.creeperfear.progress.AuraRank; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class AuraConfigLoaderTest { + @Test + void loadsPerRankRequirementsMultipliersAndFeedback() throws Exception { + AuraConfiguration configuration = new AuraConfigLoader().load(validConfiguration()); + + assertEquals(125, configuration.rules().requirementForCurrentRank(AuraRank.LOCKED)); + assertEquals(2.5, configuration.rules().damageMultiplier(AuraRank.I)); + assertEquals(140L, configuration.progressBarTicks()); + assertEquals("Aura %rank%", configuration.rankUpTitle()); + } + + @Test + void rejectsUnsafeValues() throws Exception { + YamlConfiguration config = validConfiguration(); + config.set("aura.damage-multipliers.III", -1.0); + + assertThrows(IllegalArgumentException.class, () -> new AuraConfigLoader().load(config)); + } + + private YamlConfiguration validConfiguration() throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(""" + progression: + requirements: {I: 125, II: 100, III: 100, IV: 100, V: 100, VI: 100} + aura: + damage-multipliers: {I: 2.5, II: 2.0, III: 1.5, IV: 1.0, V: 0.5, VI: 0.0} + feedback: + progress-bar-seconds: 7 + rank-up-title: "Aura %rank%" + rank-up-subtitle: "Unlocked" + """); + return config; + } +}