feat(config): configure aura progression
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <rank> <points>` 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 <rank> <points>` 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuraRank, Integer> requirements;
|
||||
private final Map<AuraRank, Double> damageMultipliers;
|
||||
private volatile Map<AuraRank, Integer> requirements;
|
||||
private volatile Map<AuraRank, Double> damageMultipliers;
|
||||
|
||||
public AuraRules(Map<AuraRank, Integer> requirements, Map<AuraRank, Double> damageMultipliers) {
|
||||
this.requirements = new EnumMap<>(requirements);
|
||||
this.damageMultipliers = new EnumMap<>(damageMultipliers);
|
||||
replace(requirements, damageMultipliers);
|
||||
}
|
||||
|
||||
public static AuraRules defaults() {
|
||||
Map<AuraRank, Integer> 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<AuraRank, Double> 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<AuraRank, Integer> newRequirements, Map<AuraRank, Double> newMultipliers) {
|
||||
EnumMap<AuraRank, Integer> 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<AuraRank, Double> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<AuraRank, Integer> 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<AuraRank, Double> 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, BossBar> bars = new HashMap<>();
|
||||
private final Map<UUID, BukkitTask> 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());
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Unlock Creeper Aura ranks by defeating creepers.
|
||||
commands:
|
||||
creeperaura:
|
||||
description: Check and administer Creeper Aura progression.
|
||||
usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>>
|
||||
usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>|threshold <rank> <points>|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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user