Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320c8ce193 | ||
|
|
5609a0a656 | ||
|
|
e064c7398c |
@@ -1,6 +1,6 @@
|
|||||||
# Leaf
|
# Leaf
|
||||||
|
|
||||||
Leaf is a Spigot 26.2 plugin that lets non-aggressive players opt into visible Resistance protection.
|
Leaf is a Spigot 26.2 plugin that lets non-aggressive players opt into visible Resistance and Strength protection.
|
||||||
|
|
||||||
The approved behavior is specified in the [OKF design bundle](design/index.md).
|
The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||||
|
|
||||||
@@ -24,7 +24,8 @@ The plugin JAR is written to `build/libs/`.
|
|||||||
/leaf off
|
/leaf off
|
||||||
/leaf status
|
/leaf status
|
||||||
/leaf enabled <on|off>
|
/leaf enabled <on|off>
|
||||||
/leaf strength <1-5>
|
/leaf effect <resistance|strength> <1-5>
|
||||||
|
/leaf strength <1-5> # Deprecated Resistance alias
|
||||||
/leaf player <name|uuid> status
|
/leaf player <name|uuid> status
|
||||||
/leaf player <name|uuid> enabled <on|off>
|
/leaf player <name|uuid> enabled <on|off>
|
||||||
/leaf player <name|uuid> locked <on|off>
|
/leaf player <name|uuid> locked <on|off>
|
||||||
@@ -34,9 +35,11 @@ Administrative commands require `leaf.admin`, granted to server operators by def
|
|||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
Leaf prefixes the standard Spigot display name (used by standard chat), player-list name, and a dedicated `leaf_protected` main-scoreboard team for overhead names. Legacy `&` color codes are supported. Leaf does not move players out of an existing scoreboard team, and per-viewer/custom scoreboards may not show its overhead prefix. Chat or tab-management plugins may replace Leaf's decorated names. Leaf restores a name only when it still matches the value Leaf installed, so unsupported integrations do not affect protection state or get overwritten during cleanup.
|
Leaf prefixes the standard Spigot display name (used by standard chat), player-list name, and a dedicated `leaf_protected` main-scoreboard team for overhead names. Legacy `&` color codes are supported. Leaf does not move players out of an existing scoreboard team, and per-viewer/custom scoreboards may not show its overhead prefix. Chat or tab-management plugins may replace Leaf's decorated names. During cleanup, Leaf removes exact copies of its configured prefix while preserving surrounding name formatting; another plugin should therefore not intentionally use identical prefix text.
|
||||||
|
|
||||||
Spigot identifies potion effects by type but does not expose their owning plugin. Leaf tracks the exact infinite, quiet Resistance effect it successfully installed and removes it only while the visible effect still matches. A distinct Resistance level, duration, or presentation is preserved. Spigot cannot distinguish an externally supplied effect with an identical fingerprint; Leaf therefore does not claim or later remove an identical effect that was already active when reconciliation ran.
|
Spigot identifies potion effects by type but does not expose their owning plugin. Leaf tracks the exact infinite, quiet Resistance and Strength effects it successfully installed and removes them only while each visible effect still matches. A distinct level, duration, or presentation is preserved. Spigot cannot distinguish an externally supplied effect with an identical fingerprint; Leaf therefore does not claim or later remove an identical effect that was already active when reconciliation ran.
|
||||||
|
|
||||||
|
Leaf reconciles opted-in online players approximately once per second. Effects cleared by death, milk, commands, or another plugin are restored automatically. A temporary external Resistance or Strength effect is left untouched, the leaf prefix remains visible, and Leaf restores its configured effect after the external effect ends.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ okf_version: "0.1"
|
|||||||
|
|
||||||
# Leaf Design
|
# Leaf Design
|
||||||
|
|
||||||
This bundle documents Leaf, a Spigot plugin that lets players opt into a visible Resistance boost while requiring them to relinquish that protection when they attack another player.
|
This bundle documents Leaf, a Spigot plugin that lets players opt into visible Resistance and Strength boosts while requiring them to relinquish that protection when they attack another player.
|
||||||
|
|
||||||
## Explore
|
## Explore
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,27 @@
|
|||||||
- Kept reserved OKF index and log documents in their specification-defined structures.
|
- Kept reserved OKF index and log documents in their specification-defined structures.
|
||||||
- Verified all user stories are done, all acceptance criteria are checked, the repository has no remote, and no push was performed.
|
- Verified all user stories are done, all acceptance criteria are checked, the repository has no remote, and no push was performed.
|
||||||
- Final verification passed with `./gradlew clean check jar` and `okflint` validation.
|
- Final verification passed with `./gradlew clean check jar` and `okflint` validation.
|
||||||
|
|
||||||
|
### US-003 sticky-prefix regression fixed
|
||||||
|
|
||||||
|
- Reproduced the cleanup failure that occurred when another plugin changed a Leaf-decorated display name.
|
||||||
|
- Leaf now removes exact managed-prefix text without discarding surrounding third-party formatting, clears stale prefixes after reload, and normalizes repeated prefixes before applying one leaf.
|
||||||
|
- Added regression coverage for wrapped names, duplicate stale prefixes, untracked stale prefixes, unrelated formatting, tab names, and scoreboard cleanup.
|
||||||
|
- Verified the fix with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
### Strength protection added
|
||||||
|
|
||||||
|
- Opted-in players now receive quiet, infinite Strength I alongside Resistance I by default; both effects must be active for Leaf status to report active protection.
|
||||||
|
- Added independent `resistance-level` and `strength-level` settings with live `/leaf effect <resistance|strength> <1-5>` administration and positional autocomplete.
|
||||||
|
- Retained `/leaf strength <1-5>` as a deprecated compatibility alias for changing Resistance.
|
||||||
|
- Opt-out, combat, administrative disablement, and global disablement remove both Leaf-managed effects while conservatively preserving distinct external effects.
|
||||||
|
- Minecraft calculates the initiating PvP hit before Leaf can process the damage event, so that first hit can include the configured Strength bonus before automatic opt-out.
|
||||||
|
- Verified settings, runtime reconciliation, status, combat cleanup, persistence, command compatibility, autocomplete, and the complete build with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
### Automatic effect recovery added
|
||||||
|
|
||||||
|
- Leaf now reconciles opted-in online players every 20 ticks so death, milk, commands, and plugins cannot permanently clear configured protection.
|
||||||
|
- Recovery remains disabled for opted-out players and while Leaf is globally disabled.
|
||||||
|
- A temporary external Resistance or Strength effect is preserved instead of overwritten; the leaf prefix remains visible and Leaf restores its configured effect after the external effect ends.
|
||||||
|
- Added regression coverage for the one-second recovery task and eligibility-aware online-player reconciliation.
|
||||||
|
- Verified the complete build with `./gradlew clean check jar`.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ As a **player**, I want to opt into Leaf protection so that I can receive a mode
|
|||||||
- [x] Opt-in choices are keyed by UUID and survive logout and server restart.
|
- [x] Opt-in choices are keyed by UUID and survive logout and server restart.
|
||||||
- [x] A player who joins while opted in regains protection when Leaf is enabled.
|
- [x] A player who joins while opted in regains protection when Leaf is enabled.
|
||||||
- [x] Player-command autocomplete suggests only valid next arguments available to the sender.
|
- [x] Player-command autocomplete suggests only valid next arguments available to the sender.
|
||||||
|
- [x] Opted-in players receive quiet Strength I alongside Resistance I by default.
|
||||||
|
- [x] Player status reports active protection only when both configured Leaf effects are effective.
|
||||||
|
- [x] Missing Leaf effects are restored for opted-in online players within approximately one second after death, milk, commands, or plugins clear them.
|
||||||
|
- [x] Recovery remains suppressed while Leaf is globally disabled or the player is opted out.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ As a **player facing PvP**, I want Leaf protection to belong only to non-aggress
|
|||||||
|
|
||||||
- [x] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out.
|
- [x] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out.
|
||||||
- [x] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix.
|
- [x] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix.
|
||||||
|
- [x] Automatic opt-out also immediately removes Leaf-managed Strength.
|
||||||
- [x] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted.
|
- [x] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted.
|
||||||
- [x] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance.
|
- [x] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance.
|
||||||
- [x] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player.
|
- [x] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player.
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ As a **server participant**, I want protected players to be visibly identified s
|
|||||||
- [x] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled.
|
- [x] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled.
|
||||||
- [x] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist.
|
- [x] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist.
|
||||||
- [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
- [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
||||||
|
- [x] Repeated opt-in or reconciliation never produces multiple Leaf prefixes.
|
||||||
|
- [x] Opt-out removes every managed Leaf prefix while preserving unrelated name formatting.
|
||||||
|
- [x] A stale Leaf prefix left by an earlier plugin instance is normalized instead of duplicated.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ As a **server administrator**, I want to control Leaf globally and per player so
|
|||||||
- [x] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied.
|
- [x] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied.
|
||||||
- [x] PvP-triggered opt-out remains authoritative even for a locked player.
|
- [x] PvP-triggered opt-out remains authoritative even for a locked player.
|
||||||
- [x] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name.
|
- [x] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name.
|
||||||
- [x] `/leaf strength <1-5>` changes the live Resistance level and immediately updates protected online players.
|
- [x] `/leaf strength <1-5>` remains a deprecated compatibility alias that changes the live Resistance level.
|
||||||
|
- [x] `/leaf effect resistance <1-5>` changes the live Resistance level and immediately updates protected online players.
|
||||||
|
- [x] `/leaf effect strength <1-5>` changes the live Strength level and immediately updates protected online players.
|
||||||
|
- [x] Effect-command autocomplete suggests effect names and valid levels for the current argument position.
|
||||||
- [x] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position.
|
- [x] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position.
|
||||||
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ As a **server operator**, I want Leaf settings and player state to be validated
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
|
- [x] Configuration supports the global enabled state, Resistance and Strength levels, leaf prefix, onboarding duration, and player-facing messages.
|
||||||
- [x] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
|
- [x] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
|
||||||
- [x] Runtime changes made through `/leaf enabled` and `/leaf strength` are persisted for subsequent restarts.
|
- [x] Strength defaults to level I and accepts only Minecraft Strength levels I through V.
|
||||||
|
- [x] Runtime changes made through `/leaf enabled` and `/leaf effect` are persisted for subsequent restarts.
|
||||||
- [x] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
|
- [x] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
|
||||||
- [x] Date-times use RFC 3339 UTC notation with a `Z` suffix.
|
- [x] Date-times use RFC 3339 UTC notation with a `Z` suffix.
|
||||||
- [x] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
|
- [x] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
|
||||||
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||||
- [x] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
|
- [x] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
|
||||||
- [x] Removing Leaf-managed Resistance does not remove a distinct Resistance effect that Leaf does not own when the API provides enough information to distinguish it.
|
- [x] Removing Leaf-managed Resistance or Strength does not remove a distinct corresponding effect that Leaf does not own when the API provides enough information to distinguish it.
|
||||||
|
- [x] A temporary external Resistance or Strength effect is preserved instead of overwritten, and Leaf restores its configured effect after the external effect ends.
|
||||||
|
- [x] The leaf prefix remains visible while an external effect temporarily replaces a Leaf-managed effect.
|
||||||
- [x] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
- [x] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import org.bukkit.entity.Player;
|
|||||||
/** Implements the permission-aware /leaf command tree. */
|
/** Implements the permission-aware /leaf command tree. */
|
||||||
public final class LeafCommand implements TabExecutor {
|
public final class LeafCommand implements TabExecutor {
|
||||||
private static final List<String> PLAYER_COMMANDS = List.of("on", "off", "status");
|
private static final List<String> PLAYER_COMMANDS = List.of("on", "off", "status");
|
||||||
private static final List<String> ADMIN_COMMANDS = List.of("enabled", "strength", "player");
|
private static final List<String> ADMIN_COMMANDS =
|
||||||
|
List.of("enabled", "effect", "strength", "player");
|
||||||
private static final List<String> ON_OFF = List.of("on", "off");
|
private static final List<String> ON_OFF = List.of("on", "off");
|
||||||
|
private static final List<String> EFFECTS = List.of("resistance", "strength");
|
||||||
private static final List<String> LEVELS = List.of("1", "2", "3", "4", "5");
|
private static final List<String> LEVELS = List.of("1", "2", "3", "4", "5");
|
||||||
private static final List<String> PLAYER_PROPERTIES = List.of("status", "enabled", "locked");
|
private static final List<String> PLAYER_PROPERTIES = List.of("status", "enabled", "locked");
|
||||||
private final LeafRuntime runtime;
|
private final LeafRuntime runtime;
|
||||||
@@ -73,11 +75,15 @@ public final class LeafCommand implements TabExecutor {
|
|||||||
if (arguments.length == 2) {
|
if (arguments.length == 2) {
|
||||||
return switch (root) {
|
return switch (root) {
|
||||||
case "enabled" -> matching(ON_OFF, arguments[1]);
|
case "enabled" -> matching(ON_OFF, arguments[1]);
|
||||||
|
case "effect" -> matching(EFFECTS, arguments[1]);
|
||||||
case "strength" -> matching(LEVELS, arguments[1]);
|
case "strength" -> matching(LEVELS, arguments[1]);
|
||||||
case "player" -> matching(runtime.knownTargets(), arguments[1]);
|
case "player" -> matching(runtime.knownTargets(), arguments[1]);
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (arguments.length == 3 && root.equals("effect")) {
|
||||||
|
return matching(LEVELS, arguments[2]);
|
||||||
|
}
|
||||||
if (arguments.length == 3 && root.equals("player")) {
|
if (arguments.length == 3 && root.equals("player")) {
|
||||||
return matching(PLAYER_PROPERTIES, arguments[2]);
|
return matching(PLAYER_PROPERTIES, arguments[2]);
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,8 @@ public final class LeafCommand implements TabExecutor {
|
|||||||
}
|
}
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "enabled" -> executeGlobalEnabled(sender, arguments);
|
case "enabled" -> executeGlobalEnabled(sender, arguments);
|
||||||
case "strength" -> executeStrength(sender, arguments);
|
case "effect" -> executeEffect(sender, arguments);
|
||||||
|
case "strength" -> executeLegacyResistanceStrength(sender, arguments);
|
||||||
case "player" -> executeTargeted(sender, arguments);
|
case "player" -> executeTargeted(sender, arguments);
|
||||||
default -> throw new IllegalStateException("validated action was not handled");
|
default -> throw new IllegalStateException("validated action was not handled");
|
||||||
}
|
}
|
||||||
@@ -130,24 +137,56 @@ public final class LeafCommand implements TabExecutor {
|
|||||||
sender.sendMessage(changeMessage("Global Leaf", enabled, change));
|
sender.sendMessage(changeMessage("Global Leaf", enabled, change));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void executeStrength(CommandSender sender, String[] arguments) throws IOException {
|
private void executeEffect(CommandSender sender, String[] arguments) throws IOException {
|
||||||
|
if (arguments.length != 3) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Usage: /leaf effect <resistance|strength> <1-5>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
String effect = arguments[1].toLowerCase(Locale.ROOT);
|
||||||
|
int level = parseLevel(arguments[2]);
|
||||||
|
LeafRuntime.Change change = switch (effect) {
|
||||||
|
case "resistance" -> runtime.setResistanceLevel(level);
|
||||||
|
case "strength" -> runtime.setStrengthLevel(level);
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"effect must be resistance or strength"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
String display = effect.equals("resistance") ? "Resistance" : "Strength";
|
||||||
|
sender.sendMessage(
|
||||||
|
change == LeafRuntime.Change.CHANGED
|
||||||
|
? "Leaf " + display + " level changed to " + level + "."
|
||||||
|
: "Leaf " + display + " level was already " + level + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeLegacyResistanceStrength(CommandSender sender, String[] arguments)
|
||||||
|
throws IOException {
|
||||||
if (arguments.length != 2) {
|
if (arguments.length != 2) {
|
||||||
throw new IllegalArgumentException("Usage: /leaf strength <1-5>");
|
throw new IllegalArgumentException("Usage: /leaf strength <1-5>");
|
||||||
}
|
}
|
||||||
int level;
|
int level = parseLevel(arguments[1]);
|
||||||
try {
|
|
||||||
level = Integer.parseInt(arguments[1]);
|
|
||||||
} catch (NumberFormatException exception) {
|
|
||||||
throw new IllegalArgumentException("strength must be an integer from 1 through 5", exception);
|
|
||||||
}
|
|
||||||
LeafRuntime.Change change = runtime.setResistanceLevel(level);
|
LeafRuntime.Change change = runtime.setResistanceLevel(level);
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
change == LeafRuntime.Change.CHANGED
|
change == LeafRuntime.Change.CHANGED
|
||||||
? "Leaf Resistance strength changed to " + level + "."
|
? "Leaf Resistance level changed to " + level
|
||||||
: "Leaf Resistance strength was already " + level + "."
|
+ ". Use /leaf effect resistance in future."
|
||||||
|
: "Leaf Resistance level was already " + level
|
||||||
|
+ ". Use /leaf effect resistance in future."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int parseLevel(String value) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"effect level must be an integer from 1 through 5",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void executeTargeted(CommandSender sender, String[] arguments) throws IOException {
|
private void executeTargeted(CommandSender sender, String[] arguments) throws IOException {
|
||||||
if (arguments.length < 3 || arguments.length > 4) {
|
if (arguments.length < 3 || arguments.length > 4) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
@@ -220,7 +259,7 @@ public final class LeafCommand implements TabExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void sendUsage(CommandSender sender) {
|
private static void sendUsage(CommandSender sender) {
|
||||||
sender.sendMessage("Usage: /leaf <on|off|status|enabled|strength|player>");
|
sender.sendMessage("Usage: /leaf <on|off|status|enabled|effect|strength|player>");
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<String> matching(List<String> candidates, String partial) {
|
static List<String> matching(List<String> candidates, String partial) {
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ import org.bukkit.scoreboard.Team;
|
|||||||
public final class LeafIdentity {
|
public final class LeafIdentity {
|
||||||
private static final String TEAM_NAME = "leaf_protected";
|
private static final String TEAM_NAME = "leaf_protected";
|
||||||
|
|
||||||
private record Names(String display, String playerList, String decoratedDisplay, String decoratedList) { }
|
|
||||||
|
|
||||||
private final Server server;
|
private final Server server;
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
private final Map<UUID, Names> names = new HashMap<>();
|
private final Map<UUID, String> managedPrefixes = new HashMap<>();
|
||||||
private final Map<UUID, String> teamEntries = new HashMap<>();
|
private final Map<UUID, String> teamEntries = new HashMap<>();
|
||||||
|
|
||||||
public LeafIdentity(Server server, Logger logger) {
|
public LeafIdentity(Server server, Logger logger) {
|
||||||
@@ -28,43 +26,57 @@ public final class LeafIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void apply(Player player, String configuredPrefix) {
|
public synchronized void apply(Player player, String configuredPrefix) {
|
||||||
String prefix = ChatColor.translateAlternateColorCodes('&', configuredPrefix);
|
String prefix = color(configuredPrefix);
|
||||||
UUID playerId = player.getUniqueId();
|
UUID playerId = player.getUniqueId();
|
||||||
Names previous = names.get(playerId);
|
String previousPrefix = managedPrefixes.get(playerId);
|
||||||
String baseDisplay = previous == null ? player.getDisplayName() : previous.display();
|
String display = normalized(player.getDisplayName(), previousPrefix, prefix);
|
||||||
String baseList = previous == null ? player.getPlayerListName() : previous.playerList();
|
String playerList = normalized(player.getPlayerListName(), previousPrefix, prefix);
|
||||||
String decoratedDisplay = prefix + baseDisplay;
|
String decoratedDisplay = prefix + display;
|
||||||
String decoratedList = prefix + baseList;
|
String decoratedList = prefix + playerList;
|
||||||
|
|
||||||
if (previous == null || player.getDisplayName().equals(previous.decoratedDisplay())) {
|
if (!player.getDisplayName().equals(decoratedDisplay)) {
|
||||||
player.setDisplayName(decoratedDisplay);
|
player.setDisplayName(decoratedDisplay);
|
||||||
}
|
}
|
||||||
if (previous == null || player.getPlayerListName().equals(previous.decoratedList())) {
|
if (!player.getPlayerListName().equals(decoratedList)) {
|
||||||
player.setPlayerListName(decoratedList);
|
player.setPlayerListName(decoratedList);
|
||||||
}
|
}
|
||||||
names.put(playerId, new Names(baseDisplay, baseList, decoratedDisplay, decoratedList));
|
managedPrefixes.put(playerId, prefix);
|
||||||
applyOverhead(player, prefix);
|
applyOverhead(player, prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void remove(Player player) {
|
public synchronized void remove(Player player, String configuredPrefix) {
|
||||||
UUID playerId = player.getUniqueId();
|
UUID playerId = player.getUniqueId();
|
||||||
Names installed = names.remove(playerId);
|
String configured = color(configuredPrefix);
|
||||||
if (installed != null) {
|
String installed = managedPrefixes.remove(playerId);
|
||||||
if (player.getDisplayName().equals(installed.decoratedDisplay())) {
|
String display = normalized(player.getDisplayName(), installed, configured);
|
||||||
player.setDisplayName(installed.display());
|
String playerList = normalized(player.getPlayerListName(), installed, configured);
|
||||||
}
|
|
||||||
if (player.getPlayerListName().equals(installed.decoratedList())) {
|
if (!player.getDisplayName().equals(display)) {
|
||||||
player.setPlayerListName(installed.playerList());
|
player.setDisplayName(display);
|
||||||
}
|
}
|
||||||
|
if (!player.getPlayerListName().equals(playerList)) {
|
||||||
|
player.setPlayerListName(playerList);
|
||||||
}
|
}
|
||||||
|
|
||||||
String entry = teamEntries.remove(playerId);
|
String entry = teamEntries.remove(playerId);
|
||||||
Team team = leafTeam(false);
|
Team team = leafTeam(false);
|
||||||
if (entry != null && team != null) {
|
if (team != null) {
|
||||||
team.removeEntry(entry);
|
team.removeEntry(entry == null ? player.getName() : entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalized(String value, String previousPrefix, String prefix) {
|
||||||
|
String normalized = value;
|
||||||
|
if (previousPrefix != null && !previousPrefix.equals(prefix)) {
|
||||||
|
normalized = normalized.replace(previousPrefix, "");
|
||||||
|
}
|
||||||
|
return normalized.replace(prefix, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String color(String configuredPrefix) {
|
||||||
|
return ChatColor.translateAlternateColorCodes('&', configuredPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
private void applyOverhead(Player player, String prefix) {
|
private void applyOverhead(Player player, String prefix) {
|
||||||
Scoreboard scoreboard = mainScoreboard();
|
Scoreboard scoreboard = mainScoreboard();
|
||||||
if (scoreboard == null) {
|
if (scoreboard == null) {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getServer().getScheduler().runTaskTimer(
|
||||||
|
this,
|
||||||
|
new LeafRecoveryTask(runtime),
|
||||||
|
LeafRecoveryTask.INTERVAL_TICKS,
|
||||||
|
LeafRecoveryTask.INTERVAL_TICKS
|
||||||
|
);
|
||||||
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
|
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
|
||||||
getLogger().info("Leaf enabled.");
|
getLogger().info("Leaf enabled.");
|
||||||
}
|
}
|
||||||
@@ -79,6 +85,7 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
void persistRuntimeSettings(LeafSettings settings) {
|
void persistRuntimeSettings(LeafSettings settings) {
|
||||||
getConfig().set("enabled", settings.enabled());
|
getConfig().set("enabled", settings.enabled());
|
||||||
getConfig().set("resistance-level", settings.resistanceLevel());
|
getConfig().set("resistance-level", settings.resistanceLevel());
|
||||||
|
getConfig().set("strength-level", settings.strengthLevel());
|
||||||
saveConfig();
|
saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,54 +7,72 @@ import org.bukkit.entity.Player;
|
|||||||
import org.bukkit.potion.PotionEffect;
|
import org.bukkit.potion.PotionEffect;
|
||||||
import org.bukkit.potion.PotionEffectType;
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
/** Applies and conservatively removes the Resistance effect owned by Leaf. */
|
/** Applies and conservatively removes the Resistance and Strength effects owned by Leaf. */
|
||||||
public final class LeafProtection {
|
public final class LeafProtection {
|
||||||
private final Map<UUID, PotionEffect> appliedEffects = new HashMap<>();
|
private final Map<UUID, Map<PotionEffectType, PotionEffect>> appliedEffects = new HashMap<>();
|
||||||
|
private final PotionEffectType resistanceType;
|
||||||
|
private final PotionEffectType strengthType;
|
||||||
|
|
||||||
public synchronized boolean apply(Player player, int level) {
|
public LeafProtection() {
|
||||||
PotionEffect desired = effectForLevel(level);
|
this(PotionEffectType.RESISTANCE, PotionEffectType.STRENGTH);
|
||||||
PotionEffect previous = appliedEffects.get(player.getUniqueId());
|
}
|
||||||
if (previous != null && !previous.equals(desired)) {
|
|
||||||
removeMatching(player, previous);
|
|
||||||
appliedEffects.remove(player.getUniqueId());
|
|
||||||
}
|
|
||||||
|
|
||||||
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
LeafProtection(PotionEffectType resistanceType, PotionEffectType strengthType) {
|
||||||
if (desired.equals(active) && desired.equals(appliedEffects.get(player.getUniqueId()))) {
|
this.resistanceType = resistanceType;
|
||||||
return true;
|
this.strengthType = strengthType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean apply(Player player, int resistanceLevel, int strengthLevel) {
|
||||||
|
boolean resistance = applyEffect(
|
||||||
|
player,
|
||||||
|
effectFor(resistanceType, resistanceLevel)
|
||||||
|
);
|
||||||
|
boolean strength = applyEffect(
|
||||||
|
player,
|
||||||
|
effectFor(strengthType, strengthLevel)
|
||||||
|
);
|
||||||
|
if (!resistance || !strength) {
|
||||||
|
remove(player);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
boolean applied = player.addPotionEffect(desired);
|
return true;
|
||||||
if (applied) {
|
|
||||||
appliedEffects.put(player.getUniqueId(), desired);
|
|
||||||
}
|
|
||||||
return applied || desired.equals(active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void remove(Player player) {
|
public synchronized void remove(Player player) {
|
||||||
PotionEffect expected = appliedEffects.remove(player.getUniqueId());
|
Map<PotionEffectType, PotionEffect> expected = appliedEffects.remove(player.getUniqueId());
|
||||||
if (expected != null) {
|
if (expected == null) {
|
||||||
removeMatching(player, expected);
|
return;
|
||||||
|
}
|
||||||
|
for (PotionEffect effect : expected.values()) {
|
||||||
|
removeMatching(player, effect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized boolean isEffective(Player player, int level) {
|
public synchronized boolean isEffective(
|
||||||
return effectForLevel(level).equals(
|
Player player,
|
||||||
player.getPotionEffect(PotionEffectType.RESISTANCE)
|
int resistanceLevel,
|
||||||
|
int strengthLevel
|
||||||
|
) {
|
||||||
|
return effectFor(resistanceType, resistanceLevel).equals(
|
||||||
|
player.getPotionEffect(resistanceType)
|
||||||
|
) && effectFor(strengthType, strengthLevel).equals(
|
||||||
|
player.getPotionEffect(strengthType)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized boolean owns(Player player) {
|
public synchronized boolean owns(Player player) {
|
||||||
PotionEffect expected = appliedEffects.get(player.getUniqueId());
|
Map<PotionEffectType, PotionEffect> expected = appliedEffects.get(player.getUniqueId());
|
||||||
return expected != null
|
return expected != null && !expected.isEmpty() && expected.values().stream().allMatch(
|
||||||
&& expected.equals(player.getPotionEffect(PotionEffectType.RESISTANCE));
|
effect -> effect.equals(player.getPotionEffect(effect.getType()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static PotionEffect effectForLevel(int level) {
|
static PotionEffect effectFor(PotionEffectType type, int level) {
|
||||||
if (level < 1 || level > 5) {
|
if (level < 1 || level > 5) {
|
||||||
throw new IllegalArgumentException("Resistance level must be between 1 and 5");
|
throw new IllegalArgumentException("effect level must be between 1 and 5");
|
||||||
}
|
}
|
||||||
return new PotionEffect(
|
return new PotionEffect(
|
||||||
PotionEffectType.RESISTANCE,
|
type,
|
||||||
PotionEffect.INFINITE_DURATION,
|
PotionEffect.INFINITE_DURATION,
|
||||||
level - 1,
|
level - 1,
|
||||||
true,
|
true,
|
||||||
@@ -63,10 +81,51 @@ public final class LeafProtection {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean applyEffect(Player player, PotionEffect desired) {
|
||||||
|
UUID playerId = player.getUniqueId();
|
||||||
|
Map<PotionEffectType, PotionEffect> owned = appliedEffects.computeIfAbsent(
|
||||||
|
playerId,
|
||||||
|
ignored -> new HashMap<>()
|
||||||
|
);
|
||||||
|
PotionEffect previous = owned.get(desired.getType());
|
||||||
|
PotionEffect active = player.getPotionEffect(desired.getType());
|
||||||
|
if (previous != null && previous.equals(active)) {
|
||||||
|
if (previous.equals(desired)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
removeMatching(player, previous);
|
||||||
|
owned.remove(desired.getType());
|
||||||
|
active = player.getPotionEffect(desired.getType());
|
||||||
|
} else if (previous != null) {
|
||||||
|
owned.remove(desired.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active != null) {
|
||||||
|
removeEmptyOwnership(playerId, owned);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean applied = player.addPotionEffect(desired);
|
||||||
|
if (applied) {
|
||||||
|
owned.put(desired.getType(), desired);
|
||||||
|
}
|
||||||
|
removeEmptyOwnership(playerId, owned);
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeEmptyOwnership(
|
||||||
|
UUID playerId,
|
||||||
|
Map<PotionEffectType, PotionEffect> owned
|
||||||
|
) {
|
||||||
|
if (owned.isEmpty()) {
|
||||||
|
appliedEffects.remove(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void removeMatching(Player player, PotionEffect expected) {
|
private static void removeMatching(Player player, PotionEffect expected) {
|
||||||
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
PotionEffect active = player.getPotionEffect(expected.getType());
|
||||||
if (expected.equals(active)) {
|
if (expected.equals(active)) {
|
||||||
player.removePotionEffect(PotionEffectType.RESISTANCE);
|
player.removePotionEffect(expected.getType());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Periodically restores eligible protection after effects are cleared or expire. */
|
||||||
|
public final class LeafRecoveryTask implements Runnable {
|
||||||
|
static final long INTERVAL_TICKS = 20L;
|
||||||
|
|
||||||
|
private final LeafRuntime runtime;
|
||||||
|
|
||||||
|
public LeafRecoveryTask(LeafRuntime runtime) {
|
||||||
|
this.runtime = Objects.requireNonNull(runtime, "runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
runtime.reconcileAllOnline();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,18 @@ public final class LeafRuntime {
|
|||||||
return Change.CHANGED;
|
return Change.CHANGED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Change setStrengthLevel(int level) throws IOException {
|
||||||
|
LeafSettings current = settingsProvider.current();
|
||||||
|
LeafSettings replacement = current.withStrengthLevel(level);
|
||||||
|
if (current.strengthLevel() == level) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
settingsPersistence.save(replacement);
|
||||||
|
settingsProvider.replace(replacement);
|
||||||
|
reconcileAllOnline();
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
public Change setLocked(UUID playerId, boolean locked) throws IOException {
|
public Change setLocked(UUID playerId, boolean locked) throws IOException {
|
||||||
PlayerLeafState state = requiredState(playerId);
|
PlayerLeafState state = requiredState(playerId);
|
||||||
if (state.locked() == locked) {
|
if (state.locked() == locked) {
|
||||||
@@ -160,7 +172,11 @@ public final class LeafRuntime {
|
|||||||
boolean active = online != null
|
boolean active = online != null
|
||||||
&& globallyEnabled
|
&& globallyEnabled
|
||||||
&& state.optedIn()
|
&& state.optedIn()
|
||||||
&& protection.isEffective(online, settingsProvider.current().resistanceLevel());
|
&& protection.isEffective(
|
||||||
|
online,
|
||||||
|
settingsProvider.current().resistanceLevel(),
|
||||||
|
settingsProvider.current().strengthLevel()
|
||||||
|
);
|
||||||
return new Status(
|
return new Status(
|
||||||
state.optedIn(),
|
state.optedIn(),
|
||||||
active,
|
active,
|
||||||
@@ -219,10 +235,14 @@ public final class LeafRuntime {
|
|||||||
PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null);
|
PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null);
|
||||||
LeafSettings settings = settingsProvider.current();
|
LeafSettings settings = settingsProvider.current();
|
||||||
if (state != null && state.optedIn() && settings.enabled()) {
|
if (state != null && state.optedIn() && settings.enabled()) {
|
||||||
if (protection.apply(player, settings.resistanceLevel())) {
|
if (protection.apply(
|
||||||
|
player,
|
||||||
|
settings.resistanceLevel(),
|
||||||
|
settings.strengthLevel()
|
||||||
|
)) {
|
||||||
identity.apply(player, settings.prefix());
|
identity.apply(player, settings.prefix());
|
||||||
} else {
|
} else {
|
||||||
identity.remove(player);
|
identity.remove(player, settings.prefix());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
removePresentation(player);
|
removePresentation(player);
|
||||||
@@ -231,7 +251,7 @@ public final class LeafRuntime {
|
|||||||
|
|
||||||
public void removePresentation(Player player) {
|
public void removePresentation(Player player) {
|
||||||
protection.remove(player);
|
protection.remove(player);
|
||||||
identity.remove(player);
|
identity.remove(player, settingsProvider.current().prefix());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reconcileAllOnline() {
|
public void reconcileAllOnline() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.util.Objects;
|
|||||||
public record LeafSettings(
|
public record LeafSettings(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
int resistanceLevel,
|
int resistanceLevel,
|
||||||
|
int strengthLevel,
|
||||||
String prefix,
|
String prefix,
|
||||||
int onboardingDays,
|
int onboardingDays,
|
||||||
String welcomeMessage,
|
String welcomeMessage,
|
||||||
@@ -16,6 +17,9 @@ public record LeafSettings(
|
|||||||
if (resistanceLevel < 1 || resistanceLevel > 5) {
|
if (resistanceLevel < 1 || resistanceLevel > 5) {
|
||||||
throw new IllegalArgumentException("resistance-level must be between 1 and 5");
|
throw new IllegalArgumentException("resistance-level must be between 1 and 5");
|
||||||
}
|
}
|
||||||
|
if (strengthLevel < 1 || strengthLevel > 5) {
|
||||||
|
throw new IllegalArgumentException("strength-level must be between 1 and 5");
|
||||||
|
}
|
||||||
if (onboardingDays <= 0) {
|
if (onboardingDays <= 0) {
|
||||||
throw new IllegalArgumentException("onboarding-days must be positive");
|
throw new IllegalArgumentException("onboarding-days must be positive");
|
||||||
}
|
}
|
||||||
@@ -30,12 +34,13 @@ public record LeafSettings(
|
|||||||
return new LeafSettings(
|
return new LeafSettings(
|
||||||
bool(values, "enabled", true),
|
bool(values, "enabled", true),
|
||||||
integer(values, "resistance-level", 1),
|
integer(values, "resistance-level", 1),
|
||||||
|
integer(values, "strength-level", 1),
|
||||||
string(values, "prefix", "&a🍃 "),
|
string(values, "prefix", "&a🍃 "),
|
||||||
integer(values, "onboarding-days", 7),
|
integer(values, "onboarding-days", 7),
|
||||||
string(
|
string(
|
||||||
values,
|
values,
|
||||||
"welcome-message",
|
"welcome-message",
|
||||||
"&aLeaf protection is available: /leaf on, /leaf off, or /leaf status. "
|
"&aLeaf Resistance and Strength are available: /leaf on, /leaf off, or /leaf status. "
|
||||||
+ "Attacking another player opts you out."
|
+ "Attacking another player opts you out."
|
||||||
),
|
),
|
||||||
string(
|
string(
|
||||||
@@ -52,6 +57,7 @@ public record LeafSettings(
|
|||||||
return new LeafSettings(
|
return new LeafSettings(
|
||||||
newEnabled,
|
newEnabled,
|
||||||
resistanceLevel,
|
resistanceLevel,
|
||||||
|
strengthLevel,
|
||||||
prefix,
|
prefix,
|
||||||
onboardingDays,
|
onboardingDays,
|
||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
@@ -64,6 +70,20 @@ public record LeafSettings(
|
|||||||
return new LeafSettings(
|
return new LeafSettings(
|
||||||
enabled,
|
enabled,
|
||||||
level,
|
level,
|
||||||
|
strengthLevel,
|
||||||
|
prefix,
|
||||||
|
onboardingDays,
|
||||||
|
welcomeMessage,
|
||||||
|
combatDisabledMessage,
|
||||||
|
lockedMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafSettings withStrengthLevel(int level) {
|
||||||
|
return new LeafSettings(
|
||||||
|
enabled,
|
||||||
|
resistanceLevel,
|
||||||
|
level,
|
||||||
prefix,
|
prefix,
|
||||||
onboardingDays,
|
onboardingDays,
|
||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Whether Leaf protection is available server-wide.
|
# Whether Leaf protection is available server-wide.
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Visible Minecraft Resistance level (I-V).
|
# Visible Minecraft Resistance and Strength levels (I-V).
|
||||||
resistance-level: 1
|
resistance-level: 1
|
||||||
|
strength-level: 1
|
||||||
|
|
||||||
# Legacy color codes are supported.
|
# Legacy color codes are supported.
|
||||||
prefix: "&a🍃 "
|
prefix: "&a🍃 "
|
||||||
@@ -10,6 +11,6 @@ prefix: "&a🍃 "
|
|||||||
# Calendar-day onboarding window measured from first join.
|
# Calendar-day onboarding window measured from first join.
|
||||||
onboarding-days: 7
|
onboarding-days: 7
|
||||||
|
|
||||||
welcome-message: "&aLeaf protection is available: /leaf on, /leaf off, or /leaf status. Attacking another player opts you out."
|
welcome-message: "&aLeaf Resistance and Strength are available: /leaf on, /leaf off, or /leaf status. Attacking another player opts you out."
|
||||||
combat-disabled-message: "&cLeaf protection was disabled because you attacked another player. You may use /leaf on again when permitted."
|
combat-disabled-message: "&cLeaf protection was disabled because you attacked another player. You may use /leaf on again when permitted."
|
||||||
locked-message: "&cAn administrator locked your Leaf setting."
|
locked-message: "&cAn administrator locked your Leaf setting."
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ name: Leaf
|
|||||||
version: ${version}
|
version: ${version}
|
||||||
main: games.dmg.leaf.LeafPlugin
|
main: games.dmg.leaf.LeafPlugin
|
||||||
api-version: "1.20"
|
api-version: "1.20"
|
||||||
description: Voluntary visible Resistance protection for non-aggressive players.
|
description: Voluntary visible Resistance and Strength protection for non-aggressive players.
|
||||||
author: dmg.games
|
author: dmg.games
|
||||||
commands:
|
commands:
|
||||||
leaf:
|
leaf:
|
||||||
description: Control or administer Leaf protection.
|
description: Control or administer Leaf protection.
|
||||||
usage: /leaf <on|off|status|enabled|strength|player>
|
usage: /leaf <on|off|status|enabled|effect|strength|player>
|
||||||
permissions:
|
permissions:
|
||||||
leaf.use:
|
leaf.use:
|
||||||
description: Allows a player to control their own Leaf protection.
|
description: Allows a player to control their own Leaf protection.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ final class LeafCommandTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void administratorCanMutateGlobalStrengthAndTargetedSettings() throws Exception {
|
void administratorCanMutateGlobalEffectsAndTargetedSettings() throws Exception {
|
||||||
LeafRuntime runtime = mock(LeafRuntime.class);
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
UUID targetId = UUID.randomUUID();
|
UUID targetId = UUID.randomUUID();
|
||||||
PlayerLeafState state = new PlayerLeafState(
|
PlayerLeafState state = new PlayerLeafState(
|
||||||
@@ -62,6 +62,8 @@ final class LeafCommandTest {
|
|||||||
when(runtime.playerState(targetId)).thenReturn(state);
|
when(runtime.playerState(targetId)).thenReturn(state);
|
||||||
when(runtime.setGlobalEnabled(false)).thenReturn(LeafRuntime.Change.CHANGED);
|
when(runtime.setGlobalEnabled(false)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
when(runtime.setResistanceLevel(5)).thenReturn(LeafRuntime.Change.CHANGED);
|
when(runtime.setResistanceLevel(5)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
|
when(runtime.setResistanceLevel(4)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
|
when(runtime.setStrengthLevel(2)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
when(runtime.setChoice(targetId, true)).thenReturn(LeafRuntime.Change.CHANGED);
|
when(runtime.setChoice(targetId, true)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
CommandSender admin = mock(CommandSender.class);
|
CommandSender admin = mock(CommandSender.class);
|
||||||
when(admin.hasPermission("leaf.admin")).thenReturn(true);
|
when(admin.hasPermission("leaf.admin")).thenReturn(true);
|
||||||
@@ -69,7 +71,19 @@ final class LeafCommandTest {
|
|||||||
Command command = mock(Command.class);
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
leaf.onCommand(admin, command, "leaf", new String[] {"enabled", "off"});
|
leaf.onCommand(admin, command, "leaf", new String[] {"enabled", "off"});
|
||||||
leaf.onCommand(admin, command, "leaf", new String[] {"strength", "5"});
|
leaf.onCommand(
|
||||||
|
admin,
|
||||||
|
command,
|
||||||
|
"leaf",
|
||||||
|
new String[] {"effect", "resistance", "5"}
|
||||||
|
);
|
||||||
|
leaf.onCommand(
|
||||||
|
admin,
|
||||||
|
command,
|
||||||
|
"leaf",
|
||||||
|
new String[] {"effect", "strength", "2"}
|
||||||
|
);
|
||||||
|
leaf.onCommand(admin, command, "leaf", new String[] {"strength", "4"});
|
||||||
leaf.onCommand(
|
leaf.onCommand(
|
||||||
admin,
|
admin,
|
||||||
command,
|
command,
|
||||||
@@ -79,6 +93,8 @@ final class LeafCommandTest {
|
|||||||
|
|
||||||
verify(runtime).setGlobalEnabled(false);
|
verify(runtime).setGlobalEnabled(false);
|
||||||
verify(runtime).setResistanceLevel(5);
|
verify(runtime).setResistanceLevel(5);
|
||||||
|
verify(runtime).setResistanceLevel(4);
|
||||||
|
verify(runtime).setStrengthLevel(2);
|
||||||
verify(runtime).setChoice(targetId, true);
|
verify(runtime).setChoice(targetId, true);
|
||||||
verify(admin, atLeastOnce()).sendMessage(contains("changed"));
|
verify(admin, atLeastOnce()).sendMessage(contains("changed"));
|
||||||
}
|
}
|
||||||
@@ -93,9 +109,22 @@ final class LeafCommandTest {
|
|||||||
Command command = mock(Command.class);
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("enabled", "strength", "player"),
|
List.of("enabled", "effect", "strength", "player"),
|
||||||
leaf.onTabComplete(admin, command, "leaf", new String[] {""})
|
leaf.onTabComplete(admin, command, "leaf", new String[] {""})
|
||||||
);
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("resistance", "strength"),
|
||||||
|
leaf.onTabComplete(admin, command, "leaf", new String[] {"effect", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("1", "2", "3", "4", "5"),
|
||||||
|
leaf.onTabComplete(
|
||||||
|
admin,
|
||||||
|
command,
|
||||||
|
"leaf",
|
||||||
|
new String[] {"effect", "strength", ""}
|
||||||
|
)
|
||||||
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("Alex"),
|
List.of("Alex"),
|
||||||
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "A"})
|
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "A"})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class LeafIdentityTest {
|
|||||||
|
|
||||||
when(fixtures.player().getDisplayName()).thenReturn(decorated);
|
when(fixtures.player().getDisplayName()).thenReturn(decorated);
|
||||||
when(fixtures.player().getPlayerListName()).thenReturn(decorated);
|
when(fixtures.player().getPlayerListName()).thenReturn(decorated);
|
||||||
identity.remove(fixtures.player());
|
identity.remove(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
verify(fixtures.player()).setDisplayName("Alex");
|
verify(fixtures.player()).setDisplayName("Alex");
|
||||||
verify(fixtures.player()).setPlayerListName("Alex");
|
verify(fixtures.player()).setPlayerListName("Alex");
|
||||||
@@ -38,7 +38,48 @@ final class LeafIdentityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void leavesThirdPartyChangesAndTeamsUntouched() {
|
void removesManagedPrefixWhilePreservingLaterThirdPartyFormatting() {
|
||||||
|
Fixtures fixtures = fixtures();
|
||||||
|
LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger());
|
||||||
|
identity.apply(fixtures.player(), "&a🍃 ");
|
||||||
|
when(fixtures.player().getDisplayName()).thenReturn("[Admin] §a🍃 Alex");
|
||||||
|
when(fixtures.player().getPlayerListName()).thenReturn("[Admin] §a🍃 Alex");
|
||||||
|
|
||||||
|
identity.remove(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
|
verify(fixtures.player()).setDisplayName("[Admin] Alex");
|
||||||
|
verify(fixtures.player()).setPlayerListName("[Admin] Alex");
|
||||||
|
verify(fixtures.team()).removeEntry("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesRepeatedStalePrefixesWhenApplying() {
|
||||||
|
Fixtures fixtures = fixtures();
|
||||||
|
when(fixtures.player().getDisplayName()).thenReturn("§a🍃 §a🍃 Alex");
|
||||||
|
when(fixtures.player().getPlayerListName()).thenReturn("§a🍃 §a🍃 Alex");
|
||||||
|
LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger());
|
||||||
|
|
||||||
|
identity.apply(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
|
verify(fixtures.player()).setDisplayName("§a🍃 Alex");
|
||||||
|
verify(fixtures.player()).setPlayerListName("§a🍃 Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesStalePrefixWithoutRuntimeTracking() {
|
||||||
|
Fixtures fixtures = fixtures();
|
||||||
|
when(fixtures.player().getDisplayName()).thenReturn("§a🍃 Alex");
|
||||||
|
when(fixtures.player().getPlayerListName()).thenReturn("§a🍃 Alex");
|
||||||
|
LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger());
|
||||||
|
|
||||||
|
identity.remove(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
|
verify(fixtures.player()).setDisplayName("Alex");
|
||||||
|
verify(fixtures.player()).setPlayerListName("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leavesUnrelatedThirdPartyChangesAndTeamsUntouched() {
|
||||||
Fixtures fixtures = fixtures();
|
Fixtures fixtures = fixtures();
|
||||||
Team otherTeam = mock(Team.class);
|
Team otherTeam = mock(Team.class);
|
||||||
when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam);
|
when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam);
|
||||||
@@ -47,7 +88,7 @@ final class LeafIdentityTest {
|
|||||||
when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex");
|
when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex");
|
||||||
when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex");
|
when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex");
|
||||||
|
|
||||||
identity.remove(fixtures.player());
|
identity.remove(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
verify(otherTeam, never()).removeEntry("Alex");
|
verify(otherTeam, never()).removeEntry("Alex");
|
||||||
verify(fixtures.player(), never()).setDisplayName("Alex");
|
verify(fixtures.player(), never()).setDisplayName("Alex");
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafRecoveryTaskTest {
|
||||||
|
@Test
|
||||||
|
void reconcilesAllOnlinePlayersEverySecond() {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
LeafRecoveryTask recovery = new LeafRecoveryTask(runtime);
|
||||||
|
|
||||||
|
recovery.run();
|
||||||
|
|
||||||
|
verify(runtime).reconcileAllOnline();
|
||||||
|
assertEquals(20L, LeafRecoveryTask.INTERVAL_TICKS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,18 +33,71 @@ final class LeafRuntimeTest {
|
|||||||
Server server = mock(Server.class);
|
Server server = mock(Server.class);
|
||||||
when(server.getPlayer(playerId)).thenReturn(player);
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
LeafProtection protection = mock(LeafProtection.class);
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
when(protection.apply(player, 1)).thenReturn(true);
|
when(protection.apply(player, 1, 1)).thenReturn(true);
|
||||||
Path stateFile = temporaryDirectory.resolve("state.yml");
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
LeafRuntime runtime = runtime(server, protection, stateFile);
|
LeafRuntime runtime = runtime(server, protection, stateFile);
|
||||||
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
|
||||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setOwnChoice(player, true));
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setOwnChoice(player, true));
|
||||||
verify(protection).apply(player, 1);
|
verify(protection).apply(player, 1, 1);
|
||||||
|
|
||||||
LeafRuntime restarted = runtime(server, protection, stateFile);
|
LeafRuntime restarted = runtime(server, protection, stateFile);
|
||||||
restarted.reconcile(player);
|
restarted.reconcile(player);
|
||||||
assertTrue(restarted.status(playerId).savedChoice());
|
assertTrue(restarted.status(playerId).savedChoice());
|
||||||
verify(protection, org.mockito.Mockito.times(2)).apply(player, 1);
|
verify(protection, org.mockito.Mockito.times(2)).apply(player, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIsActiveOnlyWhenBothConfiguredEffectsAreEffective() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
|
when(protection.apply(player, 1, 1)).thenReturn(true);
|
||||||
|
when(protection.isEffective(player, 1, 1)).thenReturn(true);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("both-effects.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setOwnChoice(player, true);
|
||||||
|
|
||||||
|
assertTrue(runtime.status(playerId).activeProtection());
|
||||||
|
verify(protection).isEffective(player, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoveryReconcilesEligiblePlayersAndLeavesOptedOutPlayersUnprotected()
|
||||||
|
throws Exception {
|
||||||
|
UUID protectedId = UUID.randomUUID();
|
||||||
|
UUID optedOutId = UUID.randomUUID();
|
||||||
|
Player protectedPlayer = player(protectedId, "Alex");
|
||||||
|
Player optedOutPlayer = player(optedOutId, "Steve");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(protectedId)).thenReturn(protectedPlayer);
|
||||||
|
when(server.getPlayer(optedOutId)).thenReturn(optedOutPlayer);
|
||||||
|
org.mockito.Mockito.doReturn(List.of(protectedPlayer, optedOutPlayer))
|
||||||
|
.when(server).getOnlinePlayers();
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
|
when(protection.apply(protectedPlayer, 1, 1)).thenReturn(true);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("recovery.yml")
|
||||||
|
);
|
||||||
|
Instant observedAt = Instant.parse("2026-08-10T00:00:00Z");
|
||||||
|
runtime.observe(protectedPlayer, observedAt);
|
||||||
|
runtime.observe(optedOutPlayer, observedAt);
|
||||||
|
runtime.setOwnChoice(protectedPlayer, true);
|
||||||
|
org.mockito.Mockito.clearInvocations(protection);
|
||||||
|
|
||||||
|
runtime.reconcileAllOnline();
|
||||||
|
|
||||||
|
verify(protection).apply(protectedPlayer, 1, 1);
|
||||||
|
verify(protection, never()).apply(optedOutPlayer, 1, 1);
|
||||||
|
verify(protection).remove(optedOutPlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,7 +140,7 @@ final class LeafRuntimeTest {
|
|||||||
|
|
||||||
runtime.observe(player, Instant.parse("2026-08-01T00:00:00Z"));
|
runtime.observe(player, Instant.parse("2026-08-01T00:00:00Z"));
|
||||||
|
|
||||||
verify(player, never()).sendMessage(contains("Leaf protection is available"));
|
verify(player, never()).sendMessage(contains("Leaf Resistance and Strength are available"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,8 +151,9 @@ final class LeafRuntimeTest {
|
|||||||
when(server.getPlayer(playerId)).thenReturn(player);
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
org.mockito.Mockito.doReturn(List.of(player)).when(server).getOnlinePlayers();
|
org.mockito.Mockito.doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||||
LeafProtection protection = mock(LeafProtection.class);
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
when(protection.apply(player, 1)).thenReturn(true);
|
when(protection.apply(player, 1, 1)).thenReturn(true);
|
||||||
when(protection.apply(player, 4)).thenReturn(true);
|
when(protection.apply(player, 4, 1)).thenReturn(true);
|
||||||
|
when(protection.apply(player, 4, 3)).thenReturn(true);
|
||||||
LeafIdentity identity = mock(LeafIdentity.class);
|
LeafIdentity identity = mock(LeafIdentity.class);
|
||||||
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
|
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
|
||||||
ArrayList<LeafSettings> persisted = new ArrayList<>();
|
ArrayList<LeafSettings> persisted = new ArrayList<>();
|
||||||
@@ -119,13 +173,17 @@ final class LeafRuntimeTest {
|
|||||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(false));
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(false));
|
||||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(true));
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(true));
|
||||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setResistanceLevel(4));
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setResistanceLevel(4));
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setStrengthLevel(3));
|
||||||
|
|
||||||
assertEquals(3, persisted.size());
|
assertEquals(4, persisted.size());
|
||||||
assertFalse(persisted.get(0).enabled());
|
assertFalse(persisted.get(0).enabled());
|
||||||
assertEquals(4, settings.current().resistanceLevel());
|
assertEquals(4, settings.current().resistanceLevel());
|
||||||
|
assertEquals(3, settings.current().strengthLevel());
|
||||||
|
assertEquals(3, persisted.get(3).strengthLevel());
|
||||||
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
verify(protection).apply(player, 4);
|
verify(protection).apply(player, 4, 1);
|
||||||
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player);
|
verify(protection).apply(player, 4, 3);
|
||||||
|
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player, "&a🍃 ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -195,7 +253,7 @@ final class LeafRuntimeTest {
|
|||||||
|
|
||||||
assertEquals(LeafRuntime.Change.LOCKED, runtime.setOwnChoice(player, true));
|
assertEquals(LeafRuntime.Change.LOCKED, runtime.setOwnChoice(player, true));
|
||||||
assertFalse(runtime.status(playerId).savedChoice());
|
assertFalse(runtime.status(playerId).savedChoice());
|
||||||
verify(protection, never()).apply(player, 1);
|
verify(protection, never()).apply(player, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -204,9 +262,10 @@ final class LeafRuntimeTest {
|
|||||||
Player player = player(playerId, "Alex");
|
Player player = player(playerId, "Alex");
|
||||||
Server server = mock(Server.class);
|
Server server = mock(Server.class);
|
||||||
when(server.getPlayer(playerId)).thenReturn(player);
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
LeafRuntime runtime = runtime(
|
LeafRuntime runtime = runtime(
|
||||||
server,
|
server,
|
||||||
mock(LeafProtection.class),
|
protection,
|
||||||
temporaryDirectory.resolve("combat.yml")
|
temporaryDirectory.resolve("combat.yml")
|
||||||
);
|
);
|
||||||
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
@@ -217,6 +276,7 @@ final class LeafRuntimeTest {
|
|||||||
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.combatOptOut(playerId));
|
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.combatOptOut(playerId));
|
||||||
|
|
||||||
assertFalse(runtime.status(playerId).savedChoice());
|
assertFalse(runtime.status(playerId).savedChoice());
|
||||||
|
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
verify(player).sendMessage(contains("attacked another player"));
|
verify(player).sendMessage(contains("attacked another player"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class LeafSettingsTest {
|
|||||||
|
|
||||||
assertEquals(true, settings.enabled());
|
assertEquals(true, settings.enabled());
|
||||||
assertEquals(1, settings.resistanceLevel());
|
assertEquals(1, settings.resistanceLevel());
|
||||||
|
assertEquals(1, settings.strengthLevel());
|
||||||
assertEquals("&a🍃 ", settings.prefix());
|
assertEquals("&a🍃 ", settings.prefix());
|
||||||
assertEquals(7, settings.onboardingDays());
|
assertEquals(7, settings.onboardingDays());
|
||||||
}
|
}
|
||||||
@@ -24,4 +25,12 @@ final class LeafSettingsTest {
|
|||||||
() -> LeafSettings.from(Map.of("resistance-level", 6))
|
() -> LeafSettings.from(Map.of("resistance-level", 6))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsStrengthOutsideConfiguredLevels() {
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> LeafSettings.from(Map.of("strength-level", 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user