From 5609a0a656e7d1ee85c3075dc589a70108e1ba78 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 22:58:18 -0400 Subject: [PATCH] feat(protection): add configurable Strength effect --- README.md | 7 +- design/index.md | 2 +- design/log.md | 9 ++ .../us-001-opt-into-leaf-protection.md | 2 + ...02-relinquish-protection-when-attacking.md | 1 + design/user-stories/us-005-administer-leaf.md | 5 +- .../us-006-configure-and-persist-leaf.md | 7 +- src/main/java/games/dmg/leaf/LeafCommand.java | 63 +++++++++-- src/main/java/games/dmg/leaf/LeafPlugin.java | 1 + .../java/games/dmg/leaf/LeafProtection.java | 106 +++++++++++++----- src/main/java/games/dmg/leaf/LeafRuntime.java | 24 +++- .../java/games/dmg/leaf/LeafSettings.java | 22 +++- src/main/resources/config.yml | 5 +- src/main/resources/plugin.yml | 4 +- .../java/games/dmg/leaf/LeafCommandTest.java | 35 +++++- .../java/games/dmg/leaf/LeafRuntimeTest.java | 48 ++++++-- .../java/games/dmg/leaf/LeafSettingsTest.java | 9 ++ 17 files changed, 279 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index b154b63..5c7e560 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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). @@ -24,7 +24,8 @@ The plugin JAR is written to `build/libs/`. /leaf off /leaf status /leaf enabled -/leaf strength <1-5> +/leaf effect <1-5> +/leaf strength <1-5> # Deprecated Resistance alias /leaf player status /leaf player enabled /leaf player locked @@ -36,7 +37,7 @@ Administrative commands require `leaf.admin`, granted to server operators by def 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. ## Releases diff --git a/design/index.md b/design/index.md index e63bb22..7873332 100644 --- a/design/index.md +++ b/design/index.md @@ -4,7 +4,7 @@ okf_version: "0.1" # 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 diff --git a/design/log.md b/design/log.md index f99e88d..118fe52 100644 --- a/design/log.md +++ b/design/log.md @@ -86,3 +86,12 @@ - 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 <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`. diff --git a/design/user-stories/us-001-opt-into-leaf-protection.md b/design/user-stories/us-001-opt-into-leaf-protection.md index 55726d5..9354270 100644 --- a/design/user-stories/us-001-opt-into-leaf-protection.md +++ b/design/user-stories/us-001-opt-into-leaf-protection.md @@ -20,6 +20,8 @@ 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] 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] 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. ## Related diff --git a/design/user-stories/us-002-relinquish-protection-when-attacking.md b/design/user-stories/us-002-relinquish-protection-when-attacking.md index 581a8ea..f053417 100644 --- a/design/user-stories/us-002-relinquish-protection-when-attacking.md +++ b/design/user-stories/us-002-relinquish-protection-when-attacking.md @@ -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] 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] 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. diff --git a/design/user-stories/us-005-administer-leaf.md b/design/user-stories/us-005-administer-leaf.md index c2cfe32..9abcbbb 100644 --- a/design/user-stories/us-005-administer-leaf.md +++ b/design/user-stories/us-005-administer-leaf.md @@ -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] 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] `/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] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. diff --git a/design/user-stories/us-006-configure-and-persist-leaf.md b/design/user-stories/us-006-configure-and-persist-leaf.md index 8323616..aa0c50c 100644 --- a/design/user-stories/us-006-configure-and-persist-leaf.md +++ b/design/user-stories/us-006-configure-and-persist-leaf.md @@ -11,15 +11,16 @@ As a **server operator**, I want Leaf settings and player state to be validated ## 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] 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] 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] 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] 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] Unknown forward-compatible configuration and player-state fields are preserved where practical. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafCommand.java b/src/main/java/games/dmg/leaf/LeafCommand.java index f6a2214..6b00a53 100644 --- a/src/main/java/games/dmg/leaf/LeafCommand.java +++ b/src/main/java/games/dmg/leaf/LeafCommand.java @@ -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 PLAYER_COMMANDS = List.of("on", "off", "status"); - private static final List ADMIN_COMMANDS = List.of("enabled", "strength", "player"); + private static final List ADMIN_COMMANDS = + List.of("enabled", "effect", "strength", "player"); private static final List ON_OFF = List.of("on", "off"); + private static final List EFFECTS = List.of("resistance", "strength"); private static final List LEVELS = List.of("1", "2", "3", "4", "5"); private static final List 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 <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 "); + sender.sendMessage("Usage: /leaf "); } static List matching(List candidates, String partial) { diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index 783f10c..223d692 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -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(); } diff --git a/src/main/java/games/dmg/leaf/LeafProtection.java b/src/main/java/games/dmg/leaf/LeafProtection.java index 190949e..44e4e63 100644 --- a/src/main/java/games/dmg/leaf/LeafProtection.java +++ b/src/main/java/games/dmg/leaf/LeafProtection.java @@ -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 appliedEffects = new HashMap<>(); + private final Map> 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 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 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 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()); } } } diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 2948b70..002807f 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -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()); diff --git a/src/main/java/games/dmg/leaf/LeafSettings.java b/src/main/java/games/dmg/leaf/LeafSettings.java index a829280..2320c19 100644 --- a/src/main/java/games/dmg/leaf/LeafSettings.java +++ b/src/main/java/games/dmg/leaf/LeafSettings.java @@ -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, diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cd06e47..69f9292 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e943372..709bd97 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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 + usage: /leaf permissions: leaf.use: description: Allows a player to control their own Leaf protection. diff --git a/src/test/java/games/dmg/leaf/LeafCommandTest.java b/src/test/java/games/dmg/leaf/LeafCommandTest.java index 5247669..257a78f 100644 --- a/src/test/java/games/dmg/leaf/LeafCommandTest.java +++ b/src/test/java/games/dmg/leaf/LeafCommandTest.java @@ -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"}) diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index e31054b..0c6f261 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -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 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")); } diff --git a/src/test/java/games/dmg/leaf/LeafSettingsTest.java b/src/test/java/games/dmg/leaf/LeafSettingsTest.java index ac45964..c4c262a 100644 --- a/src/test/java/games/dmg/leaf/LeafSettingsTest.java +++ b/src/test/java/games/dmg/leaf/LeafSettingsTest.java @@ -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)) + ); + } }