feat(config): configure aura progression

This commit is contained in:
dmg
2026-08-08 14:30:32 -04:00
parent 3bf00ea764
commit 45fa29e290
12 changed files with 276 additions and 40 deletions
+1
View File
@@ -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. - 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-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-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 type: User Story
title: "US-006: Configure aura progression" title: "US-006: Configure aura progression"
description: Let administrators safely configure and persist per-rank requirements, multipliers, and feedback. description: Let administrators safely configure and persist per-rank requirements, multipliers, and feedback.
status: backlog status: done
--- ---
# US-006: Configure aura progression # US-006: Configure aura progression
@@ -11,18 +11,18 @@ As a **server administrator**, I want to configure progression and aura behavior
## Acceptance criteria ## Acceptance criteria
- [ ] Configuration provides documented defaults for the six per-rank kill requirements and six unlocked-rank damage multipliers. - [x] Configuration provides documented defaults for the six per-rank kill requirements and six unlocked-rank damage multipliers.
- [ ] Rank requirements are positive integers. - [x] Rank requirements are positive integers.
- [ ] Damage multipliers are finite and non-negative. - [x] Damage multipliers are finite and non-negative.
- [ ] Progress boss-bar duration and player-facing rank messages are configurable. - [x] 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. - [x] `/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. - [x] `/creeperaura reload` safely loads externally edited configuration without requiring a server restart.
- [ ] Threshold-management and reload commands require documented administrative permissions. - [x] Threshold-management and reload commands require documented administrative permissions.
- [ ] Invalid configuration is rejected with actionable diagnostics while the last valid configuration remains active. - [x] 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. - [x] 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. - [x] 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. - [x] Rank advancement resets current-tier progress to zero rather than carrying excess progress forward.
- [ ] Configuration-command changes survive plugin and server restarts. - [x] Configuration-command changes survive plugin and server restarts.
## Related ## Related
@@ -5,6 +5,8 @@ import games.dmg.creeperfear.aura.CreeperAuraListener;
import games.dmg.creeperfear.command.CreeperAuraCommand; import games.dmg.creeperfear.command.CreeperAuraCommand;
import games.dmg.creeperfear.command.ProgressMessages; import games.dmg.creeperfear.command.ProgressMessages;
import games.dmg.creeperfear.command.ProgressMutations; 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.ProgressDisplay;
import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.feedback.ProgressFeedback;
import games.dmg.creeperfear.listener.CreeperDeathListener; import games.dmg.creeperfear.listener.CreeperDeathListener;
@@ -28,8 +30,10 @@ public final class CreeperFearPlugin extends JavaPlugin {
Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3");
AuraRules auraRules = AuraRules.defaults(); AuraRules auraRules = AuraRules.defaults();
progressService = new ProgressService(new SqliteProgressRepository(databasePath), auraRules); 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), 100L);
progressFeedback = new ProgressFeedback(this, new ProgressDisplay(auraRules), displayTicks); AuraConfigurationManager configurationManager = new AuraConfigurationManager(
this, new AuraConfigLoader(), auraRules, progressFeedback);
configurationManager.applyCurrent();
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new CreeperDeathListener(progressService, progressFeedback, getLogger()), this); new CreeperDeathListener(progressService, progressFeedback, getLogger()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
@@ -44,7 +48,8 @@ public final class CreeperFearPlugin extends JavaPlugin {
progressService, progressService,
new ProgressMessages(auraRules), new ProgressMessages(auraRules),
new ProgressMutations(), new ProgressMutations(),
progressFeedback)); progressFeedback,
configurationManager));
getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId()) getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId())
.exceptionally(failure -> { .exceptionally(failure -> {
getLogger().log(Level.SEVERE, getLogger().log(Level.SEVERE,
@@ -2,24 +2,22 @@ package games.dmg.creeperfear.aura;
import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.Map; import java.util.Map;
public final class AuraRules { public final class AuraRules {
private final Map<AuraRank, Integer> requirements; private volatile Map<AuraRank, Integer> requirements;
private final Map<AuraRank, Double> damageMultipliers; private volatile Map<AuraRank, Double> damageMultipliers;
public AuraRules(Map<AuraRank, Integer> requirements, Map<AuraRank, Double> damageMultipliers) { public AuraRules(Map<AuraRank, Integer> requirements, Map<AuraRank, Double> damageMultipliers) {
this.requirements = new EnumMap<>(requirements); replace(requirements, damageMultipliers);
this.damageMultipliers = new EnumMap<>(damageMultipliers);
} }
public static AuraRules defaults() { public static AuraRules defaults() {
Map<AuraRank, Integer> requirements = new EnumMap<>(AuraRank.class); Map<AuraRank, Integer> requirements = new EnumMap<>(AuraRank.class);
for (AuraRank rank : AuraRank.values()) { for (AuraRank rank : AuraRank.values()) {
if (!rank.isMaximum()) { if (!rank.isMaximum()) requirements.put(rank, 100);
requirements.put(rank, 100);
}
} }
Map<AuraRank, Double> multipliers = new EnumMap<>(AuraRank.class); Map<AuraRank, Double> multipliers = new EnumMap<>(AuraRank.class);
multipliers.put(AuraRank.LOCKED, 1.0); multipliers.put(AuraRank.LOCKED, 1.0);
@@ -32,16 +30,36 @@ public final class AuraRules {
return new AuraRules(requirements, multipliers); 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) { public PlayerProgress advanceIfEarned(PlayerProgress progress) {
if (progress.rank().isMaximum()) { if (progress.rank().isMaximum()) return progress;
return progress; if (progress.tierKills() < requirementForCurrentRank(progress.rank())) return progress;
} return new PlayerProgress(progress.playerId(), progress.lastKnownName(), progress.rank().next(), 0);
int required = requirements.get(progress.rank());
if (progress.tierKills() < required) {
return progress;
}
return new PlayerProgress(
progress.playerId(), progress.lastKnownName(), progress.rank().next(), 0);
} }
public double damageMultiplier(AuraRank rank) { public double damageMultiplier(AuraRank rank) {
@@ -49,9 +67,7 @@ public final class AuraRules {
} }
public int requirementForCurrentRank(AuraRank rank) { public int requirementForCurrentRank(AuraRank rank) {
if (rank.isMaximum()) { if (rank.isMaximum()) throw new IllegalArgumentException("Rank VI has no next requirement");
throw new IllegalArgumentException("Rank VI has no next requirement");
}
return requirements.get(rank); return requirements.get(rank);
} }
} }
@@ -1,5 +1,6 @@
package games.dmg.creeperfear.command; package games.dmg.creeperfear.command;
import games.dmg.creeperfear.config.AuraConfigurationManager;
import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.feedback.ProgressFeedback;
import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.PlayerProgress;
@@ -22,22 +23,47 @@ public final class CreeperAuraCommand implements CommandExecutor {
private final ProgressMessages messages; private final ProgressMessages messages;
private final ProgressMutations mutations; private final ProgressMutations mutations;
private final ProgressFeedback feedback; private final ProgressFeedback feedback;
private final AuraConfigurationManager configurationManager;
public CreeperAuraCommand( public CreeperAuraCommand(
JavaPlugin plugin, JavaPlugin plugin,
ProgressService progressService, ProgressService progressService,
ProgressMessages messages, ProgressMessages messages,
ProgressMutations mutations, ProgressMutations mutations,
ProgressFeedback feedback) { ProgressFeedback feedback,
AuraConfigurationManager configurationManager) {
this.plugin = plugin; this.plugin = plugin;
this.progressService = progressService; this.progressService = progressService;
this.messages = messages; this.messages = messages;
this.mutations = mutations; this.mutations = mutations;
this.feedback = feedback; this.feedback = feedback;
this.configurationManager = configurationManager;
} }
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { 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")) { if (args.length == 1 && args[0].equalsIgnoreCase("progress")) {
return showSelf(sender); 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 { public final class ProgressFeedback implements KillFeedback, AutoCloseable {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final ProgressDisplay display; 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, BossBar> bars = new HashMap<>();
private final Map<UUID, BukkitTask> hideTasks = 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) { private void showOnServerThread(Player player, AuraRank previousRank, PlayerProgress progress) {
display.rankUpTitle(previousRank, progress.rank()).ifPresent(title -> display.rankUpTitle(previousRank, progress.rank()).ifPresent(ignored -> player.sendTitle(
player.sendTitle(title, "Unlocked!", 10, 70, 20)); rankUpTitle.replace("%rank%", progress.rank().name()),
rankUpSubtitle.replace("%rank%", progress.rank().name()),
10,
70,
20));
ProgressDisplay.State state = display.forProgress(progress); ProgressDisplay.State state = display.forProgress(progress);
if (!state.visible() || !player.isOnline()) { if (!state.visible() || !player.isOnline()) {
@@ -59,6 +65,13 @@ public final class ProgressFeedback implements KillFeedback, AutoCloseable {
plugin, () -> hide(playerId), displayTicks)); 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) { public void refreshIfVisible(Player player, PlayerProgress progress) {
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
BossBar bar = bars.get(player.getUniqueId()); BossBar bar = bars.get(player.getUniqueId());
+20
View File
@@ -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: feedback:
progress-bar-seconds: 5 progress-bar-seconds: 5
rank-up-title: "Creeper Aura %rank%"
rank-up-subtitle: "Unlocked!"
+4 -1
View File
@@ -7,7 +7,7 @@ description: Unlock Creeper Aura ranks by defeating creepers.
commands: commands:
creeperaura: creeperaura:
description: Check and administer Creeper Aura progression. 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: permissions:
creeperfear.progress: creeperfear.progress:
description: Check personal Creeper Aura progression. description: Check personal Creeper Aura progression.
@@ -18,3 +18,6 @@ permissions:
creeperfear.admin.modify: creeperfear.admin.modify:
description: Modify another player's Creeper Aura progression. description: Modify another player's Creeper Aura progression.
default: op 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;
}
}