4 Commits
Author SHA1 Message Date
dmg 3488b98abd chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 3m17s
Release / release (push) Successful in 4m12s
2026-09-09 23:17:57 -04:00
dmg 85356c5c8e feat(progression): lower default rank I threshold
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 1m12s
2026-08-11 15:15:39 -04:00
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
24 changed files with 330 additions and 364 deletions
+9
View File
@@ -0,0 +1,9 @@
# spigot-creeper-fear agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-creeper-fear/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-creeper-fear/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-creeper-fear/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+6 -4
View File
@@ -1,6 +1,6 @@
# Spigot Creeper Fear # 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 ## Requirements
@@ -9,7 +9,7 @@ A Spigot 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocke
## Progression ## Progression
Players earn one current-tier point when they kill a creeper directly, through a projectile, or through an owned tameable. A self-destructing creeper also awards one point to every player its explosion hits. Each tier defaults to 100 points and resets progress to zero when unlocked. Players earn one current-tier point when they kill a creeper directly, through a projectile, or through an owned tameable. A self-destructing creeper also awards one point to every player its explosion hits. Rank I unlocks at 25 points by default; subsequent ranks require 100 points each. Progress resets to zero when a rank is unlocked.
| Rank | Creeper damage | Block damage | | Rank | Creeper damage | Block damage |
| --- | ---: | --- | | --- | ---: | --- |
@@ -48,12 +48,14 @@ 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. 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 ## Configuration
```yaml ```yaml
progression: progression:
requirements: requirements:
I: 100 I: 25
II: 100 II: 100
III: 100 III: 100
IV: 100 IV: 100
@@ -97,4 +99,4 @@ For a local versioned artifact:
## Design ## Design
The [OKF design bundle](design/index.md) contains the architecture and completed user stories. The [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-creeper-fear/index.md) contains the architecture and completed user stories.
-48
View File
@@ -1,48 +0,0 @@
---
type: Architecture
title: Creeper Fear Plugin Architecture
description: Runtime boundaries, persistence model, and event flow for Creeper Aura progression.
---
# Creeper Fear Plugin Architecture
## Runtime
Creeper Fear targets Java 17 and Spigot API 26.2. The plugin entry point owns listeners, commands, player feedback, configuration, and a progression service.
## Progression model
Each known player is identified by UUID and has:
- a last-known player name for administrative lookup;
- a current rank (`LOCKED`, `I`, `II`, `III`, `IV`, `V`, or `VI`);
- a non-negative number of creeper kills earned within the current tier.
A qualifying kill increments current-tier progress. Unlocking the next rank resets that progress to zero. Rank VI accumulates no further progress. Lifetime kill totals are deliberately not retained.
## Persistence and threading
SQLite stores player progression in the plugin data directory. Gameplay listeners submit persistence work to a dedicated single-thread executor so database latency does not block the Minecraft server thread. Bukkit API state is captured before work leaves the server thread and is not accessed by persistence workers.
Player progress is loaded into an online cache before synchronous aura decisions. Offline administrative operations use the same serialized persistence boundary.
## Event flow
A creeper death is attributed to a player when the player directly dealt the final damage or is attributable through a projectile or owned tameable. The progression service deduplicates a creeper death and records one point.
A self-destructing creeper also awards one point to every player its explosion would have hit. Explosion awards are deduplicated independently for each creeper and player, count before armor or aura mitigation, and use the same feedback as credited kills.
For explosions, player damage events establish whether an unlocked player would have been hit before armor mitigation. Each protected player receives their own rank multiplier. If any aura activates, the corresponding creeper explosion's affected block list is cleared for everyone. Rank VI cancels the player's damage event entirely.
## 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.
## Verification
Domain and persistence behavior is exercised through public interfaces with JUnit. Bukkit-facing adapters remain thin, while build verification ensures their compatibility with Spigot API 26.2.
## Related
- [Design index](index.md)
- [User stories](user-stories/index.md)
-17
View File
@@ -1,17 +0,0 @@
---
type: Index
title: Spigot Creeper Fear Design
description: Entry point for the Spigot Creeper Fear OKF knowledge bundle.
okf_version: "0.1"
---
# Spigot Creeper Fear Design
This bundle documents a Spigot plugin in which players earn Creeper Aura ranks by defeating creepers. Unlocked ranks prevent eligible creeper explosions from breaking blocks while changing the damage dealt to protected players.
## Explore
- [User stories](user-stories/index.md)
- [Plugin architecture](architecture.md)
- [Design log](log.md)
- [Project README](../README.md)
-23
View File
@@ -1,23 +0,0 @@
---
type: Log
title: Design Log
description: Chronological record of material changes to the Spigot Creeper Fear design bundle.
---
# Design Log
## 2026-08-08
- Established the OKF v0.1 design bundle.
- Defined creeper-kill progression, six Creeper Aura ranks, explosion protection, player feedback, administration, configuration, and build/release stories.
- Selected Spigot API 26.2 and Java 17 to match the neighboring `spigot-event-producer` project.
- Replaced lifetime cumulative kill tracking with a persisted rank and current-tier progress model.
- Added the initial plugin architecture.
- Completed US-001 with asynchronous SQLite current-tier progress, direct and indirect kill attribution, bounded death deduplication, and automated tests.
- Completed US-002 with per-tier rank advancement, online aura state, creeper block protection, rank damage multipliers, and rank VI cancellation.
- Completed US-003 with temporary configurable boss bars, current-tier progress presentation, full-screen rank-up titles, and maximum-rank hiding.
- 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.
- Completed US-007 with Java 17/Spigot 26.2 Gradle builds, project documentation, Gitea CI, conventional-commit checks, semantic versioning, and release artifact publication.
-15
View File
@@ -1,15 +0,0 @@
---
type: Index
title: Creeper Fear User Stories
description: Catalog of user stories for the Spigot Creeper Fear plugin.
---
# User Stories
- [US-001: Track creeper defeats](us-001-track-creeper-defeats.md)
- [US-002: Unlock Creeper Aura ranks](us-002-unlock-creeper-aura-ranks.md)
- [US-003: Show progression and rank advancement](us-003-show-progression-and-rank-advancement.md)
- [US-004: Check personal progress](us-004-check-personal-progress.md)
- [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)
@@ -1,32 +0,0 @@
---
type: User Story
title: "US-001: Track creeper defeats"
description: Record persistent current-tier progress from creeper kills attributable to the player.
status: done
---
# US-001: Track creeper defeats
As a **player**, I want my qualifying creeper kills recorded so that my Creeper Aura progression persists.
## Acceptance criteria
- [x] A creeper kill attributable to a player adds one point to that player's current-tier progress.
- [x] Direct melee kills and indirect kills attributable to the player, including projectiles and the player's tamed wolves, count.
- [x] A single creeper death cannot award progress more than once.
- [x] A creeper explosion awards one point to every player the explosion would have hit, including when armor or aura immunity reduces final damage to zero.
- [x] Each affected player receives at most one point from a given creeper explosion, while multiple affected players can each receive a point.
- [x] Explosion-earned progress produces the same progress and rank-up feedback as a credited kill.
- [x] Progress is stored using the player's UUID rather than their mutable name.
- [x] The player's current rank and current-tier progress are persisted separately.
- [x] Lifetime creeper-kill totals are not retained.
- [x] Rank VI does not accumulate further progress.
- [x] Progress survives logout, server restarts, and player-name changes.
- [x] Progress updates are persisted without blocking the Minecraft server thread on slow storage work.
- [x] Missing or invalid persisted data is handled safely and reported to server administrators.
## Related
- [Creeper Aura ranks](us-002-unlock-creeper-aura-ranks.md)
- [Plugin architecture](../architecture.md)
- [User-story catalog](index.md)
@@ -1,42 +0,0 @@
---
type: User Story
title: "US-002: Unlock Creeper Aura ranks"
description: Protect blocks and modify creeper damage according to a player's earned aura rank.
status: done
---
# US-002: Unlock Creeper Aura ranks
As a **player**, I want Creeper Aura to become stronger as I defeat creepers so that I progressively master their explosions.
## Default progression
| Current state | Kills needed to unlock next rank | Damage to protected player | Creeper block damage |
| --- | ---: | ---: | --- |
| Locked | 100 to unlock I | 1× | Normal |
| Creeper Aura I | 100 to unlock II | 3× | Prevented |
| Creeper Aura II | 100 to unlock III | 2× | Prevented |
| Creeper Aura III | 100 to unlock IV | 1.5× | Prevented |
| Creeper Aura IV | 100 to unlock V | 1× | Prevented |
| Creeper Aura V | 100 to unlock VI | 0.5× | Prevented |
| Creeper Aura VI | Maximum rank | 0× | Prevented |
## Acceptance criteria
- [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 activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion.
- [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.
- [x] Charged creepers obey the same aura rules while retaining their vanilla base explosion strength.
- [x] Explosions from TNT, beds, respawn anchors, and non-creeper entities are unchanged.
- [x] Simultaneous exposure of players with different aura ranks is deterministic and tested.
## Related
- [Track creeper defeats](us-001-track-creeper-defeats.md)
- [Configure aura progression](us-006-configure-aura-progression.md)
- [User-story catalog](index.md)
@@ -1,28 +0,0 @@
---
type: User Story
title: "US-003: Show progression and rank advancement"
description: Give players brief current-tier progress displays and prominent rank-up notifications.
status: done
---
# US-003: Show progression and rank advancement
As a **player**, I want visible progress and rank-up notifications so that I understand my advancement without persistent screen clutter.
## Acceptance criteria
- [x] After each qualifying creeper kill, a temporary boss bar shows the player's current state and progress toward the next rank.
- [x] A locked player sees current-tier progress toward Creeper Aura I.
- [x] A ranked player sees their current Roman-numeral rank, current-tier progress, and the next rank requirement.
- [x] The display duration is configurable and defaults to a short period measured in seconds.
- [x] The boss bar is hidden automatically when its display period expires.
- [x] A rank VI player no longer sees a progress boss bar.
- [x] Each newly attained rank displays a full-screen title naming the rank.
- [x] Joining the server does not replay a previously acknowledged rank-up title.
- [x] Administrative changes update an online player's boss bar if it is currently visible.
## Related
- [Creeper Aura ranks](us-002-unlock-creeper-aura-ranks.md)
- [Check personal progress](us-004-check-personal-progress.md)
- [User-story catalog](index.md)
@@ -1,25 +0,0 @@
---
type: User Story
title: "US-004: Check personal progress"
description: Let players request their current Creeper Aura rank and tier progress through a command.
status: done
---
# US-004: Check personal progress
As a **player**, I want a command that reports my Creeper Aura progress so that I can check it at any time.
## Acceptance criteria
- [x] `/creeperaura progress` reports the player's current locked or ranked state and current-tier progress.
- [x] Before rank VI, the response reports the next rank requirement and the number of additional kills needed.
- [x] At rank VI, the response clearly reports that progression is complete.
- [x] The self-service command is available to ordinary players without administrative permission.
- [x] Console use, invalid arguments, and unavailable player data produce clear responses.
- [x] Command usage is included in plugin help and command metadata.
## Related
- [Show progression and rank advancement](us-003-show-progression-and-rank-advancement.md)
- [Administer player progression](us-005-administer-player-progression.md)
- [User-story catalog](index.md)
@@ -1,32 +0,0 @@
---
type: User Story
title: "US-005: Administer player progression"
description: Let authorized administrators inspect and modify online or offline player rank and tier progress.
status: done
---
# US-005: Administer player progression
As a **server administrator**, I want to inspect and modify player progress so that I can support players and correct mistakes.
## Acceptance criteria
- [x] `/creeperaura progress <player>` reports another player's rank, current-tier progress, next requirement, and remaining kills.
- [x] `/creeperaura set <player> <progress>` sets a non-negative current-tier progress value without implicitly changing rank.
- [x] `/creeperaura add <player> <progress>` adjusts current-tier progress without allowing a negative result.
- [x] `/creeperaura rank <player> <locked|I|II|III|IV|V|VI>` explicitly changes rank and resets current-tier progress to zero.
- [x] Rank VI never retains current-tier progress.
- [x] Inspection and modification work for known offline players as well as online players.
- [x] Players are resolved to stored UUIDs so name changes do not create duplicate progression records.
- [x] Administrative changes are persisted immediately.
- [x] An online affected player's feedback and aura state are updated after a change.
- [x] Administrative commands require distinct, documented permissions suitable for inspection and modification.
- [x] Unauthorized use does not disclose another player's progression.
- [x] Invalid player names, ambiguous identities, invalid numbers, and storage failures produce clear responses without partial changes.
- [x] Successful administrative changes are written to the server log with the actor, target, old value, and new value.
## Related
- [Check personal progress](us-004-check-personal-progress.md)
- [Configure aura progression](us-006-configure-aura-progression.md)
- [User-story catalog](index.md)
@@ -1,31 +0,0 @@
---
type: User Story
title: "US-006: Configure aura progression"
description: Let administrators safely configure and persist per-rank requirements, multipliers, and feedback.
status: done
---
# US-006: Configure aura progression
As a **server administrator**, I want to configure progression and aura behavior so that it fits my server's balance.
## Acceptance criteria
- [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
- [Creeper Aura ranks](us-002-unlock-creeper-aura-ranks.md)
- [Administer player progression](us-005-administer-player-progression.md)
- [User-story catalog](index.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-007: Build and release the plugin"
description: Give maintainers repeatable Java builds, automated verification, and versioned Gitea releases.
status: done
---
# US-007: Build and release the plugin
As a **plugin maintainer**, I want automated builds and releases so that tested, correctly versioned plugin artifacts can be distributed consistently.
## Acceptance criteria
- [x] The Gradle project compiles with Java 17 against Spigot API 26.2.
- [x] Automated tests run as part of the Gradle `check` lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
- [x] Pull requests validate conventional commit messages.
- [x] CI stores a development JAR as a workflow artifact.
- [x] Main-branch conventional commits drive semantic versioning.
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [x] Build, installation, command, permission, configuration, and release procedures are documented.
## Related
- [User-story catalog](index.md)
- [Design index](../index.md)
@@ -3,6 +3,7 @@ package games.dmg.creeperfear;
import games.dmg.creeperfear.aura.AuraRules; import games.dmg.creeperfear.aura.AuraRules;
import games.dmg.creeperfear.aura.CreeperAuraListener; import games.dmg.creeperfear.aura.CreeperAuraListener;
import games.dmg.creeperfear.command.CreeperAuraCommand; import games.dmg.creeperfear.command.CreeperAuraCommand;
import games.dmg.creeperfear.command.CreeperAuraTabCompleter;
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.AuraConfigLoader;
@@ -17,6 +18,7 @@ import games.dmg.creeperfear.progress.SqliteProgressRepository;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects; import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
public final class CreeperFearPlugin extends JavaPlugin { public final class CreeperFearPlugin extends JavaPlugin {
@@ -42,14 +44,16 @@ public final class CreeperFearPlugin extends JavaPlugin {
new PlayerSessionListener(progressService, getLogger()), this); new PlayerSessionListener(progressService, getLogger()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new CreeperAuraListener(progressService, auraRules), this); new CreeperAuraListener(progressService, auraRules), this);
Objects.requireNonNull(getCommand("creeperaura"), "creeperaura command") PluginCommand creeperAuraCommand = Objects.requireNonNull(
.setExecutor(new CreeperAuraCommand( getCommand("creeperaura"), "creeperaura command");
creeperAuraCommand.setExecutor(new CreeperAuraCommand(
this, this,
progressService, progressService,
new ProgressMessages(auraRules), new ProgressMessages(auraRules),
new ProgressMutations(), new ProgressMutations(),
progressFeedback, progressFeedback,
configurationManager)); configurationManager));
creeperAuraCommand.setTabCompleter(new CreeperAuraTabCompleter(getServer()));
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,
@@ -17,7 +17,7 @@ public final class AuraRules {
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()) requirements.put(rank, 100); if (!rank.isMaximum()) requirements.put(rank, rank == AuraRank.LOCKED ? 25 : 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);
@@ -3,11 +3,10 @@ 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 games.dmg.creeperfear.progress.ProgressService; import games.dmg.creeperfear.progress.ProgressService;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@@ -18,16 +17,10 @@ import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.EntityExplodeEvent;
public final class CreeperAuraListener implements Listener { 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 Function<UUID, Optional<PlayerProgress>> progressLookup;
private final AuraRules rules; 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) { public CreeperAuraListener(ProgressService progressService, AuraRules rules) {
this(progressService::cached, rules); this(progressService::cached, rules);
@@ -52,7 +45,6 @@ public final class CreeperAuraListener implements Listener {
return; return;
} }
markProtected(creeper.getUniqueId());
AuraRank rank = progress.rank(); AuraRank rank = progress.rank();
if (rank.isMaximum()) { if (rank.isMaximum()) {
event.setCancelled(true); event.setCancelled(true);
@@ -63,17 +55,23 @@ public final class CreeperAuraListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreeperExplode(EntityExplodeEvent event) { 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.blockList().clear();
event.setYield(0.0f); event.setYield(0.0f);
} }
} }
private synchronized void markProtected(UUID creeperId) { private boolean hasNearbyAuraPlayer(Creeper creeper) {
protectedExplosions.put(creeperId, Boolean.TRUE); 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;
} }
}
private synchronized boolean takeProtected(UUID creeperId) { return false;
return protectedExplosions.remove(creeperId) != null;
} }
} }
@@ -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
@@ -1,6 +1,6 @@
progression: progression:
requirements: requirements:
I: 100 I: 25
II: 100 II: 100
III: 100 III: 100
IV: 100 IV: 100
+1 -1
View File
@@ -6,7 +6,7 @@ author: dmg.games
description: Unlock Creeper Aura ranks by defeating creepers. 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 with permission-aware tab completion.
usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>|threshold <rank> <points>|reload> usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>|threshold <rank> <points>|reload>
permissions: permissions:
creeperfear.progress: creeperfear.progress:
@@ -11,8 +11,8 @@ class AuraRulesTest {
private final AuraRules rules = AuraRules.defaults(); private final AuraRules rules = AuraRules.defaults();
@Test @Test
void unlocksOneRankAndResetsTierProgressAtOneHundredKills() { void unlocksFirstRankAndResetsTierProgressAtTwentyFiveKills() {
PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 100); PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 25);
PlayerProgress advanced = rules.advanceIfEarned(progress); PlayerProgress advanced = rules.advanceIfEarned(progress);
@@ -20,6 +20,12 @@ class AuraRulesTest {
assertEquals(0, advanced.tierKills()); assertEquals(0, advanced.tierKills());
} }
@Test
void usesOneHundredKillsForLaterRanksByDefault() {
assertEquals(100, rules.requirementForCurrentRank(AuraRank.I));
assertEquals(100, rules.requirementForCurrentRank(AuraRank.V));
}
@Test @Test
void appliesTheDefaultDamageMultipliers() { void appliesTheDefaultDamageMultipliers() {
assertEquals(3.0, rules.damageMultiplier(AuraRank.I)); assertEquals(3.0, rules.damageMultiplier(AuraRank.I));
@@ -1,15 +1,20 @@
package games.dmg.creeperfear.aura; package games.dmg.creeperfear.aura;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
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.ArrayList; import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -20,31 +25,94 @@ import org.junit.jupiter.api.Test;
class CreeperAuraListenerTest { class CreeperAuraListenerTest {
@Test @Test
void multipliesPlayerDamageAndClearsBlocksForTheExplosion() { void multipliesDamageForAnAuraPlayerHitByACreeperExplosion() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID creeperId = UUID.randomUUID();
Player player = mock(Player.class); Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class); Creeper creeper = mock(Creeper.class);
EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.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(player.getUniqueId()).thenReturn(playerId);
when(creeper.getUniqueId()).thenReturn(creeperId);
when(damage.getEntity()).thenReturn(player); when(damage.getEntity()).thenReturn(player);
when(damage.getDamager()).thenReturn(creeper); when(damage.getDamager()).thenReturn(creeper);
when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION); when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION);
when(damage.getDamage()).thenReturn(10.0); 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.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks); when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0); PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults()); CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperDamage(damage);
listener.onCreeperExplode(explosion); listener.onCreeperExplode(explosion);
verify(damage).setDamage(30.0);
assertTrue(blocks.isEmpty()); 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 @Test
@@ -64,4 +132,10 @@ class CreeperAuraListenerTest {
verify(damage).setCancelled(true); 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[] {""}));
}
}
@@ -4,10 +4,25 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.AuraRank;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class AuraConfigLoaderTest { class AuraConfigLoaderTest {
@Test
void bundledConfigurationDefaultsFirstRankToTwentyFiveKills() throws Exception {
try (InputStream stream = getClass().getResourceAsStream("/config.yml")) {
YamlConfiguration config = YamlConfiguration.loadConfiguration(
new InputStreamReader(stream, StandardCharsets.UTF_8));
AuraConfiguration configuration = new AuraConfigLoader().load(config);
assertEquals(25, configuration.rules().requirementForCurrentRank(AuraRank.LOCKED));
assertEquals(100, configuration.rules().requirementForCurrentRank(AuraRank.I));
}
}
@Test @Test
void loadsPerRankRequirementsMultipliersAndFeedback() throws Exception { void loadsPerRankRequirementsMultipliersAndFeedback() throws Exception {
AuraConfiguration configuration = new AuraConfigLoader().load(validConfiguration()); AuraConfiguration configuration = new AuraConfigLoader().load(validConfiguration());
@@ -15,12 +15,12 @@ class ProgressDisplayTest {
@Test @Test
void describesLockedCurrentTierProgress() { void describesLockedCurrentTierProgress() {
PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 42); PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 12);
ProgressDisplay.State state = display.forProgress(progress); ProgressDisplay.State state = display.forProgress(progress);
assertEquals("Creeper Aura: Locked — 42 / 100", state.text()); assertEquals("Creeper Aura: Locked — 12 / 25", state.text());
assertEquals(0.42, state.fraction(), 0.0001); assertEquals(0.48, state.fraction(), 0.0001);
assertTrue(state.visible()); assertTrue(state.visible());
} }