feat(protection): add configurable Strength effect
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 56s

This commit is contained in:
dmg
2026-08-10 22:58:18 -04:00
parent e064c7398c
commit 5609a0a656
17 changed files with 279 additions and 71 deletions
+51 -12
View File
@@ -13,8 +13,10 @@ import org.bukkit.entity.Player;
/** Implements the permission-aware /leaf command tree. */
public final class LeafCommand implements TabExecutor {
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> EFFECTS = List.of("resistance", "strength");
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 final LeafRuntime runtime;
@@ -73,11 +75,15 @@ public final class LeafCommand implements TabExecutor {
if (arguments.length == 2) {
return switch (root) {
case "enabled" -> matching(ON_OFF, arguments[1]);
case "effect" -> matching(EFFECTS, arguments[1]);
case "strength" -> matching(LEVELS, arguments[1]);
case "player" -> matching(runtime.knownTargets(), arguments[1]);
default -> List.of();
};
}
if (arguments.length == 3 && root.equals("effect")) {
return matching(LEVELS, arguments[2]);
}
if (arguments.length == 3 && root.equals("player")) {
return matching(PLAYER_PROPERTIES, arguments[2]);
}
@@ -115,7 +121,8 @@ public final class LeafCommand implements TabExecutor {
}
switch (action) {
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);
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));
}
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) {
throw new IllegalArgumentException("Usage: /leaf strength <1-5>");
}
int level;
try {
level = Integer.parseInt(arguments[1]);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("strength must be an integer from 1 through 5", exception);
}
int level = parseLevel(arguments[1]);
LeafRuntime.Change change = runtime.setResistanceLevel(level);
sender.sendMessage(
change == LeafRuntime.Change.CHANGED
? "Leaf Resistance strength changed to " + level + "."
: "Leaf Resistance strength was already " + level + "."
? "Leaf Resistance level changed to " + 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 {
if (arguments.length < 3 || arguments.length > 4) {
throw new IllegalArgumentException(
@@ -220,7 +259,7 @@ public final class LeafCommand implements TabExecutor {
}
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) {
@@ -79,6 +79,7 @@ public final class LeafPlugin extends JavaPlugin {
void persistRuntimeSettings(LeafSettings settings) {
getConfig().set("enabled", settings.enabled());
getConfig().set("resistance-level", settings.resistanceLevel());
getConfig().set("strength-level", settings.strengthLevel());
saveConfig();
}
@@ -7,54 +7,72 @@ import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
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 {
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) {
PotionEffect desired = effectForLevel(level);
PotionEffect previous = appliedEffects.get(player.getUniqueId());
if (previous != null && !previous.equals(desired)) {
removeMatching(player, previous);
appliedEffects.remove(player.getUniqueId());
}
public LeafProtection() {
this(PotionEffectType.RESISTANCE, PotionEffectType.STRENGTH);
}
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
if (desired.equals(active) && desired.equals(appliedEffects.get(player.getUniqueId()))) {
return true;
LeafProtection(PotionEffectType resistanceType, PotionEffectType strengthType) {
this.resistanceType = resistanceType;
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);
if (applied) {
appliedEffects.put(player.getUniqueId(), desired);
}
return applied || desired.equals(active);
return true;
}
public synchronized void remove(Player player) {
PotionEffect expected = appliedEffects.remove(player.getUniqueId());
if (expected != null) {
removeMatching(player, expected);
Map<PotionEffectType, PotionEffect> expected = appliedEffects.remove(player.getUniqueId());
if (expected == null) {
return;
}
for (PotionEffect effect : expected.values()) {
removeMatching(player, effect);
}
}
public synchronized boolean isEffective(Player player, int level) {
return effectForLevel(level).equals(
player.getPotionEffect(PotionEffectType.RESISTANCE)
public synchronized boolean isEffective(
Player player,
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) {
PotionEffect expected = appliedEffects.get(player.getUniqueId());
return expected != null
&& expected.equals(player.getPotionEffect(PotionEffectType.RESISTANCE));
Map<PotionEffectType, PotionEffect> expected = appliedEffects.get(player.getUniqueId());
return expected != null && !expected.isEmpty() && expected.values().stream().allMatch(
effect -> effect.equals(player.getPotionEffect(effect.getType()))
);
}
static PotionEffect effectForLevel(int level) {
static PotionEffect effectFor(PotionEffectType type, int level) {
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(
PotionEffectType.RESISTANCE,
type,
PotionEffect.INFINITE_DURATION,
level - 1,
true,
@@ -63,10 +81,36 @@ 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());
if (previous != null && !previous.equals(desired)) {
removeMatching(player, previous);
owned.remove(desired.getType());
}
PotionEffect active = player.getPotionEffect(desired.getType());
if (desired.equals(active) && desired.equals(owned.get(desired.getType()))) {
return true;
}
boolean applied = player.addPotionEffect(desired);
if (applied) {
owned.put(desired.getType(), desired);
}
if (owned.isEmpty()) {
appliedEffects.remove(playerId);
}
return applied || desired.equals(active);
}
private static void removeMatching(Player player, PotionEffect expected) {
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
PotionEffect active = player.getPotionEffect(expected.getType());
if (expected.equals(active)) {
player.removePotionEffect(PotionEffectType.RESISTANCE);
player.removePotionEffect(expected.getType());
}
}
}
+22 -2
View File
@@ -143,6 +143,18 @@ public final class LeafRuntime {
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 {
PlayerLeafState state = requiredState(playerId);
if (state.locked() == locked) {
@@ -160,7 +172,11 @@ public final class LeafRuntime {
boolean active = online != null
&& globallyEnabled
&& state.optedIn()
&& protection.isEffective(online, settingsProvider.current().resistanceLevel());
&& protection.isEffective(
online,
settingsProvider.current().resistanceLevel(),
settingsProvider.current().strengthLevel()
);
return new Status(
state.optedIn(),
active,
@@ -219,7 +235,11 @@ public final class LeafRuntime {
PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null);
LeafSettings settings = settingsProvider.current();
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());
} else {
identity.remove(player, settings.prefix());
+21 -1
View File
@@ -6,6 +6,7 @@ import java.util.Objects;
public record LeafSettings(
boolean enabled,
int resistanceLevel,
int strengthLevel,
String prefix,
int onboardingDays,
String welcomeMessage,
@@ -16,6 +17,9 @@ public record LeafSettings(
if (resistanceLevel < 1 || resistanceLevel > 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) {
throw new IllegalArgumentException("onboarding-days must be positive");
}
@@ -30,12 +34,13 @@ public record LeafSettings(
return new LeafSettings(
bool(values, "enabled", true),
integer(values, "resistance-level", 1),
integer(values, "strength-level", 1),
string(values, "prefix", "&a🍃 "),
integer(values, "onboarding-days", 7),
string(
values,
"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."
),
string(
@@ -52,6 +57,7 @@ public record LeafSettings(
return new LeafSettings(
newEnabled,
resistanceLevel,
strengthLevel,
prefix,
onboardingDays,
welcomeMessage,
@@ -64,6 +70,20 @@ public record LeafSettings(
return new LeafSettings(
enabled,
level,
strengthLevel,
prefix,
onboardingDays,
welcomeMessage,
combatDisabledMessage,
lockedMessage
);
}
public LeafSettings withStrengthLevel(int level) {
return new LeafSettings(
enabled,
resistanceLevel,
level,
prefix,
onboardingDays,
welcomeMessage,
+3 -2
View File
@@ -1,8 +1,9 @@
# Whether Leaf protection is available server-wide.
enabled: true
# Visible Minecraft Resistance level (I-V).
# Visible Minecraft Resistance and Strength levels (I-V).
resistance-level: 1
strength-level: 1
# Legacy color codes are supported.
prefix: "&a🍃 "
@@ -10,6 +11,6 @@ prefix: "&a🍃 "
# Calendar-day onboarding window measured from first join.
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."
locked-message: "&cAn administrator locked your Leaf setting."
+2 -2
View File
@@ -2,12 +2,12 @@ name: Leaf
version: ${version}
main: games.dmg.leaf.LeafPlugin
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
commands:
leaf:
description: Control or administer Leaf protection.
usage: /leaf <on|off|status|enabled|strength|player>
usage: /leaf <on|off|status|enabled|effect|strength|player>
permissions:
leaf.use:
description: Allows a player to control their own Leaf protection.
@@ -48,7 +48,7 @@ final class LeafCommandTest {
}
@Test
void administratorCanMutateGlobalStrengthAndTargetedSettings() throws Exception {
void administratorCanMutateGlobalEffectsAndTargetedSettings() throws Exception {
LeafRuntime runtime = mock(LeafRuntime.class);
UUID targetId = UUID.randomUUID();
PlayerLeafState state = new PlayerLeafState(
@@ -62,6 +62,8 @@ final class LeafCommandTest {
when(runtime.playerState(targetId)).thenReturn(state);
when(runtime.setGlobalEnabled(false)).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);
CommandSender admin = mock(CommandSender.class);
when(admin.hasPermission("leaf.admin")).thenReturn(true);
@@ -69,7 +71,19 @@ final class LeafCommandTest {
Command command = mock(Command.class);
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(
admin,
command,
@@ -79,6 +93,8 @@ final class LeafCommandTest {
verify(runtime).setGlobalEnabled(false);
verify(runtime).setResistanceLevel(5);
verify(runtime).setResistanceLevel(4);
verify(runtime).setStrengthLevel(2);
verify(runtime).setChoice(targetId, true);
verify(admin, atLeastOnce()).sendMessage(contains("changed"));
}
@@ -93,9 +109,22 @@ final class LeafCommandTest {
Command command = mock(Command.class);
assertEquals(
List.of("enabled", "strength", "player"),
List.of("enabled", "effect", "strength", "player"),
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(
List.of("Alex"),
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "A"})
@@ -33,18 +33,39 @@ final class LeafRuntimeTest {
Server server = mock(Server.class);
when(server.getPlayer(playerId)).thenReturn(player);
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");
LeafRuntime runtime = runtime(server, protection, stateFile);
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
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);
restarted.reconcile(player);
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
@@ -87,7 +108,7 @@ final class LeafRuntimeTest {
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
@@ -98,8 +119,9 @@ final class LeafRuntimeTest {
when(server.getPlayer(playerId)).thenReturn(player);
org.mockito.Mockito.doReturn(List.of(player)).when(server).getOnlinePlayers();
LeafProtection protection = mock(LeafProtection.class);
when(protection.apply(player, 1)).thenReturn(true);
when(protection.apply(player, 4)).thenReturn(true);
when(protection.apply(player, 1, 1)).thenReturn(true);
when(protection.apply(player, 4, 1)).thenReturn(true);
when(protection.apply(player, 4, 3)).thenReturn(true);
LeafIdentity identity = mock(LeafIdentity.class);
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
ArrayList<LeafSettings> persisted = new ArrayList<>();
@@ -119,12 +141,16 @@ final class LeafRuntimeTest {
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(false));
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(true));
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());
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).apply(player, 4);
verify(protection).apply(player, 4, 1);
verify(protection).apply(player, 4, 3);
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player, "&a🍃 ");
}
@@ -195,7 +221,7 @@ final class LeafRuntimeTest {
assertEquals(LeafRuntime.Change.LOCKED, runtime.setOwnChoice(player, true));
assertFalse(runtime.status(playerId).savedChoice());
verify(protection, never()).apply(player, 1);
verify(protection, never()).apply(player, 1, 1);
}
@Test
@@ -204,9 +230,10 @@ final class LeafRuntimeTest {
Player player = player(playerId, "Alex");
Server server = mock(Server.class);
when(server.getPlayer(playerId)).thenReturn(player);
LeafProtection protection = mock(LeafProtection.class);
LeafRuntime runtime = runtime(
server,
mock(LeafProtection.class),
protection,
temporaryDirectory.resolve("combat.yml")
);
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
@@ -217,6 +244,7 @@ final class LeafRuntimeTest {
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.combatOptOut(playerId));
assertFalse(runtime.status(playerId).savedChoice());
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
verify(player).sendMessage(contains("attacked another player"));
}
@@ -13,6 +13,7 @@ final class LeafSettingsTest {
assertEquals(true, settings.enabled());
assertEquals(1, settings.resistanceLevel());
assertEquals(1, settings.strengthLevel());
assertEquals("&a🍃 ", settings.prefix());
assertEquals(7, settings.onboardingDays());
}
@@ -24,4 +25,12 @@ final class LeafSettingsTest {
() -> LeafSettings.from(Map.of("resistance-level", 6))
);
}
@Test
void rejectsStrengthOutsideConfiguredLevels() {
assertThrows(
IllegalArgumentException.class,
() -> LeafSettings.from(Map.of("strength-level", 0))
);
}
}