Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902ffc9888 | ||
|
|
54e7f306ed | ||
|
|
320c8ce193 | ||
|
|
5609a0a656 | ||
|
|
e064c7398c |
@@ -0,0 +1,9 @@
|
||||
# spigot-leaf agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-leaf/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-leaf/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-leaf/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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 [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-leaf/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -24,7 +24,8 @@ The plugin JAR is written to `build/libs/`.
|
||||
/leaf off
|
||||
/leaf status
|
||||
/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> enabled <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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
@@ -1,81 +0,0 @@
|
||||
# Leaf Design Log
|
||||
|
||||
## 2026-08-10
|
||||
|
||||
### Initial protection design
|
||||
|
||||
- Leaf is available to any player who opts in; the seven-day period applies only to onboarding reminders.
|
||||
- Protection grants Resistance I by default and remains active until the player opts out, attacks another player, an administrator disables it for the player, or Leaf is globally disabled.
|
||||
- Receiving an attack does not remove protection, but retaliating does, including attributable Thorns damage.
|
||||
- PvP attribution covers melee, projectiles, potions, owned animals, Thorns, and indirect damage when Spigot exposes a reliable responsible player.
|
||||
- Opted-in players receive a leaf prefix in tab, overhead names, and chat where supported.
|
||||
- New players receive an onboarding reminder on every login for seven calendar days after first joining.
|
||||
- Global disablement suppresses effects, prefixes, and reminders without erasing player choices.
|
||||
- Administration uses the permission-aware `/leaf` command tree with autocomplete.
|
||||
- Builds and releases follow the neighboring Spigot Base Gradle and Gitea pipeline.
|
||||
- The design bundle is committed together; later implementation work is committed separately for each user story.
|
||||
|
||||
### Implementation started
|
||||
|
||||
- Established the Java 17 Gradle project, strict compilation, JUnit test lifecycle, Leaf plugin metadata, Gradle wrapper, and Gitea CI and semantic-release workflows.
|
||||
- Verified the initial pipeline with `./gradlew clean check jar` and produced `build/libs/leaf-0.1.0-SNAPSHOT.jar`.
|
||||
- US-007 remains in progress until the story-by-story implementation commit requirement has been verified.
|
||||
|
||||
### Configuration and persistence checkpoint
|
||||
|
||||
- Added validated settings with Resistance I and seven-day onboarding defaults.
|
||||
- Added defensive UUID-keyed YAML player state with RFC 3339 timestamps, forward-field preservation, and atomic replacement where supported.
|
||||
- Added safe plugin initialization and periodic dirty-state persistence.
|
||||
- US-006 remains in progress pending live runtime setting commands and Leaf effect ownership behavior.
|
||||
|
||||
### US-001 player protection completed
|
||||
|
||||
- Added permission-aware player commands with idempotent choice changes and detailed status reporting.
|
||||
- Added immediate durable opt-in persistence, join-time restoration, and quiet infinite Resistance reconciliation.
|
||||
- Leaf tracks its live Resistance fingerprint and conservatively preserves a visibly distinct Resistance effect.
|
||||
- Verified player choice, lock, persistence, command, autocomplete, and build behavior with `./gradlew clean check jar`.
|
||||
|
||||
### US-003 identity presentation checkpoint
|
||||
|
||||
- Added configurable, color-compatible prefixes to standard chat display names and player-list names.
|
||||
- Added conservative main-scoreboard team prefixes for overhead names without displacing unrelated teams.
|
||||
- Name cleanup restores only values Leaf installed; third-party changes are preserved.
|
||||
- Documented scoreboard, custom chat, tab-list, and per-viewer scoreboard limitations.
|
||||
- US-003 remains in progress until combat and global administration paths are verified.
|
||||
|
||||
### US-002 combat opt-out completed
|
||||
|
||||
- Added modern Spigot damage-source attribution for direct attacks, projectiles, thrown potions, area clouds, tamed animals, TNT, Thorns, and other reliably attributed sources.
|
||||
- Ambiguous, cancelled, zero-damage, environmental, and self damage do not opt out a player.
|
||||
- Combat opt-out bypasses administrative locks, immediately reconciles effect and prefix state, persists once, and sends one explanatory message.
|
||||
- Verified attribution, victim safety, lock bypass, duplicate suppression, and the full build with `./gradlew clean check jar`.
|
||||
|
||||
### US-004 onboarding completed
|
||||
|
||||
- Join observation now sends the configurable welcome message on each login before the original first-join instant plus the configured calendar-day window.
|
||||
- The exact expiration boundary and globally disabled behavior suppress reminders without resetting first-join state.
|
||||
- The default message names all player commands and explains automatic combat opt-out.
|
||||
- Verified repeated joins, expiration, global suppression, first-join retention, and the full build with `./gradlew clean check jar`.
|
||||
|
||||
### US-005 administration and US-003 identity completed
|
||||
|
||||
- Added the complete `leaf.admin` command tree for global state, live strength, player status, saved choice, and locks.
|
||||
- Runtime global and strength changes persist before activation and immediately reconcile all online players.
|
||||
- Target resolution prefers exact online/current names, retains prior names, accepts UUIDs, and rejects ambiguous reused names.
|
||||
- Added position-specific administrative autocomplete and mutation/no-change reporting with validation before mutation.
|
||||
- Verified global effect and prefix suppression/restoration, live strength, targets, locks, command permissions, completion, durable aliases, and the full build with `./gradlew clean check jar`.
|
||||
|
||||
### US-006 configuration and persistence completed
|
||||
|
||||
- Runtime global and Resistance-level changes are written through Bukkit configuration before becoming active and survive restart.
|
||||
- Failed runtime setting persistence leaves the active settings unchanged.
|
||||
- Leaf records only effects it successfully installs, removes them only while the visible fingerprint matches, and preserves distinct Resistance effects; Spigot's identical-effect ownership limitation is documented.
|
||||
- Durable player state now retains prior names in addition to the required UUID, latest name, choice, lock, and RFC 3339 first-join timestamp.
|
||||
- Verified settings failure safety, durable aliases, existing defensive YAML behavior, strict compilation, and the full build with `./gradlew clean check jar`.
|
||||
|
||||
### US-007 build and release completed
|
||||
|
||||
- Verified dedicated conventional implementation commits for protection, identity, combat, onboarding, administration, configuration verification, and logout cleanup in addition to the existing build foundation.
|
||||
- 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.
|
||||
- Final verification passed with `./gradlew clean check jar` and `okflint` validation.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Leaf User Stories
|
||||
|
||||
1. [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
2. [US-002: Relinquish protection when attacking](us-002-relinquish-protection-when-attacking.md)
|
||||
3. [US-003: Identify protected players](us-003-identify-protected-players.md)
|
||||
4. [US-004: Introduce new players to Leaf](us-004-introduce-new-players-to-leaf.md)
|
||||
5. [US-005: Administer Leaf](us-005-administer-leaf.md)
|
||||
6. [US-006: Configure and persist Leaf](us-006-configure-and-persist-leaf.md)
|
||||
7. [US-007: Build and release Leaf](us-007-build-and-release-leaf.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Opt into Leaf protection"
|
||||
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Opt into Leaf protection
|
||||
|
||||
As a **player**, I want to opt into Leaf protection so that I can receive a modest defensive boost while playing on a hard server.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||
- [x] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
||||
- [x] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
||||
- [x] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
||||
- [x] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
||||
- [x] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
||||
- [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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Relinquish protection when attacking](us-002-relinquish-protection-when-attacking.md)
|
||||
- [US-003: Identify protected players](us-003-identify-protected-players.md)
|
||||
- [US-005: Administer Leaf](us-005-administer-leaf.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Relinquish protection when attacking"
|
||||
description: Remove Leaf protection when an opted-in player attacks another player.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Relinquish protection when attacking
|
||||
|
||||
As a **player facing PvP**, I want Leaf protection to belong only to non-aggressors so that protected players cannot keep its advantage after fighting back.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [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] 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.
|
||||
- [x] Direct melee attacks and player-fired projectiles are attributed to the attacking player.
|
||||
- [x] Harmful splash or lingering potion damage is attributed to the player who threw the potion.
|
||||
- [x] Damage caused by a tamed animal is attributed to its player owner when Spigot exposes that ownership.
|
||||
- [x] TNT, fire, and other indirect damage trigger opt-out only when Spigot exposes a reliable responsible player; ambiguous environmental damage does not opt out an innocent player.
|
||||
- [x] PvP-triggered opt-out applies even when an administrator has locked the player's preference.
|
||||
- [x] A single attack produces no duplicate state changes or duplicate notifications.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
- [US-003: Identify protected players](us-003-identify-protected-players.md)
|
||||
- [US-005: Administer Leaf](us-005-administer-leaf.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Identify protected players"
|
||||
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Identify protected players
|
||||
|
||||
As a **server participant**, I want protected players to be visibly identified so that their Leaf status is clear before I interact with them.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] An actively protected player has a configurable leaf prefix before their name in the player list.
|
||||
- [x] An actively protected player has the same prefix before their overhead name where Spigot supports it.
|
||||
- [x] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it.
|
||||
- [x] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting.
|
||||
- [x] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator.
|
||||
- [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] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
- [US-002: Relinquish protection when attacking](us-002-relinquish-protection-when-attacking.md)
|
||||
- [US-006: Configure and persist Leaf](us-006-configure-and-persist-leaf.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Introduce new players to Leaf"
|
||||
description: Remind players how to control Leaf during their first seven calendar days on the server.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Introduce new players to Leaf
|
||||
|
||||
As a **new player**, I want a brief explanation of Leaf when I join so that I know protection is available and understand how to control it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Leaf records the first observed join time for each player by UUID as an RFC 3339 UTC timestamp.
|
||||
- [x] On every login before the first-join timestamp plus seven calendar days, the player receives a concise welcome message explaining Leaf protection.
|
||||
- [x] The welcome message tells the player to use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||
- [x] The message makes clear that attacking another player automatically opts the attacker out.
|
||||
- [x] Players no longer receive the welcome message once their seven-day onboarding period expires.
|
||||
- [x] Existing first-join timestamps are not reset by logout, restart, opt-in changes, or global disablement.
|
||||
- [x] No onboarding reminders are shown while Leaf is globally disabled.
|
||||
- [x] Re-enabling Leaf allows reminders to resume on subsequent logins only for players whose original seven-day period has not expired.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
- [US-005: Administer Leaf](us-005-administer-leaf.md)
|
||||
- [US-006: Configure and persist Leaf](us-006-configure-and-persist-leaf.md)
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-005: Administer Leaf"
|
||||
description: Give administrators command-based control over global and per-player Leaf behavior.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Administer Leaf
|
||||
|
||||
As a **server administrator**, I want to control Leaf globally and per player so that I can support players, prevent misuse, and suspend protection for server events.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Administrative commands require `leaf.admin`, which server operators receive by default.
|
||||
- [x] `/leaf enabled <on|off>` globally enables or disables Leaf.
|
||||
- [x] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices.
|
||||
- [x] Global enablement immediately restores protection and prefixes for eligible online opted-in players.
|
||||
- [x] `/leaf player <name|uuid> status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time.
|
||||
- [x] `/leaf player <name|uuid> enabled <on|off>` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix.
|
||||
- [x] `/leaf player <name|uuid> locked <on|off>` controls whether that player can change their own saved choice.
|
||||
- [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] 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
- [US-002: Relinquish protection when attacking](us-002-relinquish-protection-when-attacking.md)
|
||||
- [US-006: Configure and persist Leaf](us-006-configure-and-persist-leaf.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-006: Configure and persist Leaf"
|
||||
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-006: Configure and persist Leaf
|
||||
|
||||
As a **server operator**, I want Leaf settings and player state to be validated and durable so that protection remains predictable across restarts.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Configuration supports the global enabled state, Resistance strength, 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] 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] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Opt into Leaf protection](us-001-opt-into-leaf-protection.md)
|
||||
- [US-004: Introduce new players to Leaf](us-004-introduce-new-players-to-leaf.md)
|
||||
- [US-005: Administer Leaf](us-005-administer-leaf.md)
|
||||
- [US-007: Build and release Leaf](us-007-build-and-release-leaf.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-007: Build and release Leaf"
|
||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-007: Build and release Leaf
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested, correctly versioned Leaf artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [x] Pushes and pull requests build and test Leaf in Gitea Actions.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI stores a development Leaf JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits drive semantic versioning.
|
||||
- [x] A successful release builds a versioned Leaf JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Leaf names and identifiers.
|
||||
- [x] The initial approved user-story bundle is kept in one documentation commit.
|
||||
- [x] Subsequent implementation work is organized into a separate conventional commit for each implemented user story.
|
||||
- [x] No remote push is performed until the maintainer confirms that the Gitea repository and release token secret are ready.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-006: Configure and persist Leaf](us-006-configure-and-persist-leaf.md)
|
||||
- [User-story catalog](index.md)
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,11 +15,9 @@ import org.bukkit.scoreboard.Team;
|
||||
public final class LeafIdentity {
|
||||
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 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<>();
|
||||
|
||||
public LeafIdentity(Server server, Logger logger) {
|
||||
@@ -28,43 +26,57 @@ public final class LeafIdentity {
|
||||
}
|
||||
|
||||
public synchronized void apply(Player player, String configuredPrefix) {
|
||||
String prefix = ChatColor.translateAlternateColorCodes('&', configuredPrefix);
|
||||
String prefix = color(configuredPrefix);
|
||||
UUID playerId = player.getUniqueId();
|
||||
Names previous = names.get(playerId);
|
||||
String baseDisplay = previous == null ? player.getDisplayName() : previous.display();
|
||||
String baseList = previous == null ? player.getPlayerListName() : previous.playerList();
|
||||
String decoratedDisplay = prefix + baseDisplay;
|
||||
String decoratedList = prefix + baseList;
|
||||
String previousPrefix = managedPrefixes.get(playerId);
|
||||
String display = normalized(player.getDisplayName(), previousPrefix, prefix);
|
||||
String playerList = normalized(player.getPlayerListName(), previousPrefix, prefix);
|
||||
String decoratedDisplay = prefix + display;
|
||||
String decoratedList = prefix + playerList;
|
||||
|
||||
if (previous == null || player.getDisplayName().equals(previous.decoratedDisplay())) {
|
||||
if (!player.getDisplayName().equals(decoratedDisplay)) {
|
||||
player.setDisplayName(decoratedDisplay);
|
||||
}
|
||||
if (previous == null || player.getPlayerListName().equals(previous.decoratedList())) {
|
||||
if (!player.getPlayerListName().equals(decoratedList)) {
|
||||
player.setPlayerListName(decoratedList);
|
||||
}
|
||||
names.put(playerId, new Names(baseDisplay, baseList, decoratedDisplay, decoratedList));
|
||||
managedPrefixes.put(playerId, prefix);
|
||||
applyOverhead(player, prefix);
|
||||
}
|
||||
|
||||
public synchronized void remove(Player player) {
|
||||
public synchronized void remove(Player player, String configuredPrefix) {
|
||||
UUID playerId = player.getUniqueId();
|
||||
Names installed = names.remove(playerId);
|
||||
if (installed != null) {
|
||||
if (player.getDisplayName().equals(installed.decoratedDisplay())) {
|
||||
player.setDisplayName(installed.display());
|
||||
}
|
||||
if (player.getPlayerListName().equals(installed.decoratedList())) {
|
||||
player.setPlayerListName(installed.playerList());
|
||||
String configured = color(configuredPrefix);
|
||||
String installed = managedPrefixes.remove(playerId);
|
||||
String display = normalized(player.getDisplayName(), installed, configured);
|
||||
String playerList = normalized(player.getPlayerListName(), installed, configured);
|
||||
|
||||
if (!player.getDisplayName().equals(display)) {
|
||||
player.setDisplayName(display);
|
||||
}
|
||||
if (!player.getPlayerListName().equals(playerList)) {
|
||||
player.setPlayerListName(playerList);
|
||||
}
|
||||
|
||||
String entry = teamEntries.remove(playerId);
|
||||
Team team = leafTeam(false);
|
||||
if (entry != null && team != null) {
|
||||
team.removeEntry(entry);
|
||||
if (team != null) {
|
||||
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) {
|
||||
Scoreboard scoreboard = mainScoreboard();
|
||||
if (scoreboard == null) {
|
||||
|
||||
@@ -37,6 +37,12 @@ public final class LeafPlugin extends JavaPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this,
|
||||
new LeafRecoveryTask(runtime),
|
||||
LeafRecoveryTask.INTERVAL_TICKS,
|
||||
LeafRecoveryTask.INTERVAL_TICKS
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
|
||||
getLogger().info("Leaf enabled.");
|
||||
}
|
||||
@@ -79,6 +85,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()))) {
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
boolean applied = player.addPotionEffect(desired);
|
||||
if (applied) {
|
||||
appliedEffects.put(player.getUniqueId(), desired);
|
||||
}
|
||||
return applied || desired.equals(active);
|
||||
}
|
||||
|
||||
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,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) {
|
||||
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
||||
PotionEffect active = player.getPotionEffect(expected.getType());
|
||||
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;
|
||||
}
|
||||
|
||||
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,10 +235,14 @@ 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);
|
||||
identity.remove(player, settings.prefix());
|
||||
}
|
||||
} else {
|
||||
removePresentation(player);
|
||||
@@ -231,7 +251,7 @@ public final class LeafRuntime {
|
||||
|
||||
public void removePresentation(Player player) {
|
||||
protection.remove(player);
|
||||
identity.remove(player);
|
||||
identity.remove(player, settingsProvider.current().prefix());
|
||||
}
|
||||
|
||||
public void reconcileAllOnline() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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"})
|
||||
|
||||
@@ -30,7 +30,7 @@ final class LeafIdentityTest {
|
||||
|
||||
when(fixtures.player().getDisplayName()).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()).setPlayerListName("Alex");
|
||||
@@ -38,7 +38,48 @@ final class LeafIdentityTest {
|
||||
}
|
||||
|
||||
@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();
|
||||
Team otherTeam = mock(Team.class);
|
||||
when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam);
|
||||
@@ -47,7 +88,7 @@ final class LeafIdentityTest {
|
||||
when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex");
|
||||
when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex");
|
||||
|
||||
identity.remove(fixtures.player());
|
||||
identity.remove(fixtures.player(), "&a🍃 ");
|
||||
|
||||
verify(otherTeam, never()).removeEntry("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);
|
||||
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
|
||||
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
|
||||
@@ -87,7 +140,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 +151,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,13 +173,17 @@ 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(identity, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||
verify(protection).apply(player, 4, 1);
|
||||
verify(protection).apply(player, 4, 3);
|
||||
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player, "&a🍃 ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -195,7 +253,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 +262,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 +276,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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user