2 Commits
Author SHA1 Message Date
dmg 55e4f04f16 feat(aura): protect blocks within player radius
Release / release (push) Successful in 2m32s
CI / build (push) Successful in 1m11s
2026-08-11 14:46:54 -04:00
dmg b4a8094874 feat(commands): add permission-aware tab completion
Release / release (push) Successful in 2m30s
CI / build (push) Successful in 1m18s
2026-08-10 22:22:08 -04:00
12 changed files with 334 additions and 37 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
# Spigot Creeper Fear
A Spigot 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents creeper explosions that hit the player from breaking blocks, trading that protection for rank-dependent player damage.
A Spigot 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents a creeper explosion within 25 blocks of the player from breaking blocks, whether or not the explosion hits the player. Players hit by the explosion receive rank-dependent damage.
## Requirements
@@ -48,6 +48,8 @@ Restart the server after copying the JAR. The plugin creates its configuration a
The personal progress permission defaults to everyone. Administrative permissions default to server operators. Administrative changes are recorded in the server log.
Commands provide permission-aware tab completion for subcommands, online player names, and valid ranks. Known offline players remain valid command targets but are not suggested.
## Configuration
```yaml
+1 -1
View File
@@ -36,7 +36,7 @@ For explosions, player damage events establish whether an unlocked player would
## Commands and configuration
`/creeperaura` exposes player progress and permission-protected offline administration. Rank requirements, multipliers, feedback duration, and messages are loaded from YAML. Valid command-based changes are written back to YAML and survive restart.
`/creeperaura` exposes player progress and permission-protected offline administration. Permission-aware tab completion suggests available subcommands, online players, and valid ranks without querying SQLite on the server thread. Rank requirements, multipliers, feedback duration, and messages are loaded from YAML. Valid command-based changes are written back to YAML and survive restart.
## Verification
+8
View File
@@ -6,6 +6,14 @@ description: Chronological record of material changes to the Spigot Creeper Fear
# Design Log
## 2026-08-11
- Updated US-002 so an unlocked player's aura protects blocks from any creeper explosion within 25 blocks, without requiring the explosion to hit the player.
## 2026-08-10
- Completed US-008 with permission-aware command, online-player, and rank tab completion.
## 2026-08-08
- Established the OKF v0.1 design bundle.
+1
View File
@@ -13,3 +13,4 @@ description: Catalog of user stories for the Spigot Creeper Fear plugin.
- [US-005: Administer player progression](us-005-administer-player-progression.md)
- [US-006: Configure aura progression](us-006-configure-aura-progression.md)
- [US-007: Build and release the plugin](us-007-build-and-release-plugin.md)
- [US-008: Autocomplete commands](us-008-autocomplete-commands.md)
@@ -26,8 +26,10 @@ As a **player**, I want Creeper Aura to become stronger as I defeat creepers so
- [x] A player unlocks the next rank after earning the configured number of kills within their current tier.
- [x] Unlocking a rank resets current-tier progress to zero.
- [x] A player below rank I receives normal creeper explosion behavior.
- [x] An aura activates when an unlocked player would have been hit by the creeper explosion, even when armor or another modifier would reduce the eventual damage to zero.
- [x] An aura protects blocks when a player with rank I or higher is within 25 blocks of a creeper at explosion time, including at exactly 25 blocks.
- [x] Block protection does not require the aura player to be hit by or have line of sight to the explosion.
- [x] An activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion.
- [x] Players beyond 25 blocks and players whose aura is locked do not activate block protection.
- [x] Other nearby players and entities continue to receive their normal creeper explosion effects unless they have their own aura damage modifier.
- [x] The protected player's rank multiplier is applied to vanilla creeper explosion damage before armor, enchantments, resistance, and difficulty mitigation.
- [x] At rank VI, the protected player's creeper explosion damage event is cancelled so that damage and knockback are nullified.
@@ -0,0 +1,31 @@
---
type: User Story
title: "US-008: Autocomplete commands"
description: Help players and administrators discover valid Creeper Aura command arguments with permission-aware tab completion.
status: done
---
# US-008: Autocomplete commands
As a **command sender**, I want Creeper Aura commands to offer relevant tab completions so that I can enter valid commands quickly and accurately.
## Acceptance criteria
- [x] `/creeperaura` suggests only subcommands allowed by the sender's permissions.
- [x] `progress`, `set`, `add`, and `rank` suggest matching online player names where appropriate.
- [x] `rank` suggests `locked`, `I`, `II`, `III`, `IV`, `V`, and `VI`.
- [x] `threshold` suggests configurable ranks `I` through `VI`.
- [x] Suggestions are filtered case-insensitively by the current input.
- [x] Unauthorized administrative subcommands and arguments are not suggested.
- [x] Completion works for both players and the server console.
- [x] Automated tests cover completion behavior.
- [x] Command metadata and project documentation describe autocomplete support.
Offline stored players remain valid command targets but are not suggested, avoiding synchronous database access during completion.
## Related
- [Check personal progress](us-004-check-personal-progress.md)
- [Administer player progression](us-005-administer-player-progression.md)
- [Configure aura progression](us-006-configure-aura-progression.md)
- [User-story catalog](index.md)
@@ -3,6 +3,7 @@ package games.dmg.creeperfear;
import games.dmg.creeperfear.aura.AuraRules;
import games.dmg.creeperfear.aura.CreeperAuraListener;
import games.dmg.creeperfear.command.CreeperAuraCommand;
import games.dmg.creeperfear.command.CreeperAuraTabCompleter;
import games.dmg.creeperfear.command.ProgressMessages;
import games.dmg.creeperfear.command.ProgressMutations;
import games.dmg.creeperfear.config.AuraConfigLoader;
@@ -17,6 +18,7 @@ import games.dmg.creeperfear.progress.SqliteProgressRepository;
import java.nio.file.Path;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class CreeperFearPlugin extends JavaPlugin {
@@ -42,14 +44,16 @@ public final class CreeperFearPlugin extends JavaPlugin {
new PlayerSessionListener(progressService, getLogger()), this);
getServer().getPluginManager().registerEvents(
new CreeperAuraListener(progressService, auraRules), this);
Objects.requireNonNull(getCommand("creeperaura"), "creeperaura command")
.setExecutor(new CreeperAuraCommand(
this,
progressService,
new ProgressMessages(auraRules),
new ProgressMutations(),
progressFeedback,
configurationManager));
PluginCommand creeperAuraCommand = Objects.requireNonNull(
getCommand("creeperaura"), "creeperaura command");
creeperAuraCommand.setExecutor(new CreeperAuraCommand(
this,
progressService,
new ProgressMessages(auraRules),
new ProgressMutations(),
progressFeedback,
configurationManager));
creeperAuraCommand.setTabCompleter(new CreeperAuraTabCompleter(getServer()));
getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId())
.exceptionally(failure -> {
getLogger().log(Level.SEVERE,
@@ -3,11 +3,10 @@ package games.dmg.creeperfear.aura;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import games.dmg.creeperfear.progress.ProgressService;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
@@ -18,16 +17,10 @@ import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.event.entity.EntityExplodeEvent;
public final class CreeperAuraListener implements Listener {
private static final int RECENT_EXPLOSION_LIMIT = 1024;
private static final double BLOCK_PROTECTION_RADIUS_SQUARED = 25.0 * 25.0;
private final Function<UUID, Optional<PlayerProgress>> progressLookup;
private final AuraRules rules;
private final Map<UUID, Boolean> protectedExplosions = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<UUID, Boolean> eldest) {
return size() > RECENT_EXPLOSION_LIMIT;
}
};
public CreeperAuraListener(ProgressService progressService, AuraRules rules) {
this(progressService::cached, rules);
@@ -52,7 +45,6 @@ public final class CreeperAuraListener implements Listener {
return;
}
markProtected(creeper.getUniqueId());
AuraRank rank = progress.rank();
if (rank.isMaximum()) {
event.setCancelled(true);
@@ -63,17 +55,23 @@ public final class CreeperAuraListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreeperExplode(EntityExplodeEvent event) {
if (event.getEntity() instanceof Creeper creeper && takeProtected(creeper.getUniqueId())) {
if (event.getEntity() instanceof Creeper creeper && hasNearbyAuraPlayer(creeper)) {
event.blockList().clear();
event.setYield(0.0f);
}
}
private synchronized void markProtected(UUID creeperId) {
protectedExplosions.put(creeperId, Boolean.TRUE);
}
private synchronized boolean takeProtected(UUID creeperId) {
return protectedExplosions.remove(creeperId) != null;
private boolean hasNearbyAuraPlayer(Creeper creeper) {
Location creeperLocation = creeper.getLocation();
for (Player player : creeper.getWorld().getPlayers()) {
if (player.getLocation().distanceSquared(creeperLocation) <= BLOCK_PROTECTION_RADIUS_SQUARED
&& progressLookup.apply(player.getUniqueId())
.map(PlayerProgress::rank)
.filter(AuraRank::isUnlocked)
.isPresent()) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,79 @@
package games.dmg.creeperfear.command;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.jetbrains.annotations.NotNull;
public final class CreeperAuraTabCompleter implements TabCompleter {
private static final List<String> ALL_RANKS = List.of("locked", "I", "II", "III", "IV", "V", "VI");
private static final List<String> CONFIGURABLE_RANKS = ALL_RANKS.subList(1, ALL_RANKS.size());
private final Server server;
public CreeperAuraTabCompleter(Server server) {
this.server = server;
}
@Override
public List<String> onTabComplete(
@NotNull CommandSender sender,
@NotNull Command command,
@NotNull String label,
@NotNull String[] args) {
if (args.length == 3
&& args[0].equalsIgnoreCase("rank")
&& sender.hasPermission("creeperfear.admin.modify")) {
return matching(ALL_RANKS, args[2]);
}
if (args.length == 2
&& args[0].equalsIgnoreCase("threshold")
&& sender.hasPermission("creeperfear.admin.configure")) {
return matching(CONFIGURABLE_RANKS, args[1]);
}
if (args.length == 2 && completesPlayer(sender, args[0])) {
List<String> playerNames = server.getOnlinePlayers().stream()
.map(player -> player.getName())
.toList();
return matching(playerNames, args[1]);
}
if (args.length != 1) return List.of();
List<String> suggestions = new ArrayList<>();
if (sender.hasPermission("creeperfear.progress")
|| sender.hasPermission("creeperfear.admin.inspect")) {
suggestions.add("progress");
}
if (sender.hasPermission("creeperfear.admin.modify")) {
suggestions.add("set");
suggestions.add("add");
suggestions.add("rank");
}
if (sender.hasPermission("creeperfear.admin.configure")) {
suggestions.add("threshold");
suggestions.add("reload");
}
return matching(suggestions, args[0]);
}
private boolean completesPlayer(CommandSender sender, String subcommand) {
if (subcommand.equalsIgnoreCase("progress")) {
return sender.hasPermission("creeperfear.admin.inspect");
}
return (subcommand.equalsIgnoreCase("set")
|| subcommand.equalsIgnoreCase("add")
|| subcommand.equalsIgnoreCase("rank"))
&& sender.hasPermission("creeperfear.admin.modify");
}
private List<String> matching(List<String> candidates, String input) {
String prefix = input.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ author: dmg.games
description: Unlock Creeper Aura ranks by defeating creepers.
commands:
creeperaura:
description: Check and administer Creeper Aura progression.
description: Check and administer Creeper Aura progression with permission-aware tab completion.
usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>|threshold <rank> <points>|reload>
permissions:
creeperfear.progress:
@@ -1,15 +1,20 @@
package games.dmg.creeperfear.aura;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player;
@@ -20,31 +25,94 @@ import org.junit.jupiter.api.Test;
class CreeperAuraListenerTest {
@Test
void multipliesPlayerDamageAndClearsBlocksForTheExplosion() {
void multipliesDamageForAnAuraPlayerHitByACreeperExplosion() {
UUID playerId = UUID.randomUUID();
UUID creeperId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = new ArrayList<>();
blocks.add(mock(Block.class));
when(player.getUniqueId()).thenReturn(playerId);
when(creeper.getUniqueId()).thenReturn(creeperId);
when(damage.getEntity()).thenReturn(player);
when(damage.getDamager()).thenReturn(creeper);
when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION);
when(damage.getDamage()).thenReturn(10.0);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperDamage(damage);
verify(damage).setDamage(30.0);
}
@Test
void protectsBlocksWhenAuraPlayerIsExactly25BlocksAwayWithoutBeingHit() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 25.0, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperDamage(damage);
listener.onCreeperExplode(explosion);
verify(damage).setDamage(30.0);
assertTrue(blocks.isEmpty());
verify(explosion).setYield(0.0f);
}
@Test
void leavesBlocksWhenAuraPlayerIsBeyond25Blocks() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 25.01, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperExplode(explosion);
assertFalse(blocks.isEmpty());
verify(explosion, never()).setYield(0.0f);
}
@Test
void leavesBlocksWhenNearbyPlayerHasNotUnlockedAura() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.LOCKED, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperExplode(explosion);
assertFalse(blocks.isEmpty());
verify(explosion, never()).setYield(0.0f);
}
@Test
@@ -64,4 +132,10 @@ class CreeperAuraListenerTest {
verify(damage).setCancelled(true);
}
private static ArrayList<Block> blocksToBreak() {
ArrayList<Block> blocks = new ArrayList<>();
blocks.add(mock(Block.class));
return blocks;
}
}
@@ -0,0 +1,98 @@
package games.dmg.creeperfear.command;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class CreeperAuraTabCompleterTest {
private final Server server = mock(Server.class);
private final Command command = mock(Command.class);
private final CommandSender sender = mock(CommandSender.class);
private final CreeperAuraTabCompleter completer = new CreeperAuraTabCompleter(server);
@Test
void suggestsOnlySubcommandsAllowedByTheSendersPermissions() {
when(sender.hasPermission("creeperfear.progress")).thenReturn(true);
when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true);
List<String> suggestions = completer.onTabComplete(sender, command, "creeperaura", new String[] {""});
assertEquals(List.of("progress", "set", "add", "rank"), suggestions);
}
@Test
void filtersSubcommandsCaseInsensitivelyByPartialInput() {
when(sender.hasPermission("creeperfear.admin.configure")).thenReturn(true);
List<String> suggestions = completer.onTabComplete(sender, command, "creeperaura", new String[] {"TH"});
assertEquals(List.of("threshold"), suggestions);
}
@Test
void suggestsMatchingOnlinePlayersForPermittedPlayerArguments() {
Player alice = mock(Player.class);
Player bob = mock(Player.class);
when(alice.getName()).thenReturn("Alice");
when(bob.getName()).thenReturn("Bob");
doReturn(List.of(alice, bob)).when(server).getOnlinePlayers();
when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true);
List<String> suggestions = completer.onTabComplete(
sender, command, "creeperaura", new String[] {"rank", "aL"});
assertEquals(List.of("Alice"), suggestions);
}
@Test
void suggestsAllRanksForTheRankCommand() {
when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true);
List<String> suggestions = completer.onTabComplete(
sender, command, "creeperaura", new String[] {"rank", "Alice", ""});
assertEquals(List.of("locked", "I", "II", "III", "IV", "V", "VI"), suggestions);
}
@Test
void suggestsOnlyConfigurableRanksForThresholds() {
when(sender.hasPermission("creeperfear.admin.configure")).thenReturn(true);
List<String> suggestions = completer.onTabComplete(
sender, command, "creeperaura", new String[] {"threshold", ""});
assertEquals(List.of("I", "II", "III", "IV", "V", "VI"), suggestions);
}
@Test
void doesNotSuggestAdministrativeArgumentsWithoutPermission() {
assertEquals(List.of(), completer.onTabComplete(
sender, command, "creeperaura", new String[] {"progress", ""}));
assertEquals(List.of(), completer.onTabComplete(
sender, command, "creeperaura", new String[] {"rank", "Alice", ""}));
assertEquals(List.of(), completer.onTabComplete(
sender, command, "creeperaura", new String[] {"threshold", ""}));
}
@Test
void completesCommandsForPlayersAndTheServerConsole() {
Player player = mock(Player.class);
ConsoleCommandSender console = mock(ConsoleCommandSender.class);
when(player.hasPermission("creeperfear.progress")).thenReturn(true);
when(console.hasPermission("creeperfear.progress")).thenReturn(true);
assertEquals(List.of("progress"), completer.onTabComplete(
player, command, "creeperaura", new String[] {""}));
assertEquals(List.of("progress"), completer.onTabComplete(
console, command, "creeperaura", new String[] {""}));
}
}