Compare commits
10
Commits
5627de6afe
...
07bc1d9382
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07bc1d9382 | ||
|
|
67675786c3 | ||
|
|
3b8393cca4 | ||
|
|
ab858df733 | ||
|
|
e53548f481 | ||
|
|
5d3acef8ab | ||
|
|
d1427fbe38 | ||
|
|
1b622211cb | ||
|
|
7deb79ad61 | ||
|
|
f805f9371d |
@@ -34,7 +34,9 @@ Administrative commands require `leaf.admin`, granted to server operators by def
|
|||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
Leaf will use standard Spigot facilities for tab-list, overhead-name, and chat prefixes. Scoreboard or chat-management plugins may override those facilities; unsupported integrations must not affect protection state.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|||||||
@@ -20,3 +20,62 @@
|
|||||||
- Established the Java 17 Gradle project, strict compilation, JUnit test lifecycle, Leaf plugin metadata, Gradle wrapper, and Gitea CI and semantic-release workflows.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-001: Opt into Leaf protection"
|
title: "US-001: Opt into Leaf protection"
|
||||||
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-001: Opt into Leaf protection
|
# US-001: Opt into Leaf protection
|
||||||
@@ -11,15 +11,15 @@ As a **player**, I want to opt into Leaf protection so that I can receive a mode
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
- [x] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||||
- [ ] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
- [x] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
||||||
- [ ] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
- [x] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
||||||
- [ ] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
- [x] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
||||||
- [ ] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
- [x] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
||||||
- [ ] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
- [x] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
||||||
- [ ] Opt-in choices are keyed by UUID and survive logout and server restart.
|
- [x] Opt-in choices are keyed by UUID and survive logout and server restart.
|
||||||
- [ ] A player who joins while opted in regains protection when Leaf is enabled.
|
- [x] A player who joins while opted in regains protection when Leaf is enabled.
|
||||||
- [ ] Player-command autocomplete suggests only valid next arguments available to the sender.
|
- [x] Player-command autocomplete suggests only valid next arguments available to the sender.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-002: Relinquish protection when attacking"
|
title: "US-002: Relinquish protection when attacking"
|
||||||
description: Remove Leaf protection when an opted-in player attacks another player.
|
description: Remove Leaf protection when an opted-in player attacks another player.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-002: Relinquish protection when attacking
|
# US-002: Relinquish protection when attacking
|
||||||
@@ -11,17 +11,17 @@ As a **player facing PvP**, I want Leaf protection to belong only to non-aggress
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out.
|
- [x] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out.
|
||||||
- [ ] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix.
|
- [x] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix.
|
||||||
- [ ] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted.
|
- [x] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted.
|
||||||
- [ ] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance.
|
- [x] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance.
|
||||||
- [ ] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player.
|
- [x] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player.
|
||||||
- [ ] Direct melee attacks and player-fired projectiles are attributed to the attacking player.
|
- [x] Direct melee attacks and player-fired projectiles are attributed to the attacking player.
|
||||||
- [ ] Harmful splash or lingering potion damage is attributed to the player who threw the potion.
|
- [x] Harmful splash or lingering potion damage is attributed to the player who threw the potion.
|
||||||
- [ ] Damage caused by a tamed animal is attributed to its player owner when Spigot exposes that ownership.
|
- [x] Damage caused by a tamed animal is attributed to its player owner when Spigot exposes that ownership.
|
||||||
- [ ] 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] 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.
|
||||||
- [ ] PvP-triggered opt-out applies even when an administrator has locked the player's preference.
|
- [x] PvP-triggered opt-out applies even when an administrator has locked the player's preference.
|
||||||
- [ ] A single attack produces no duplicate state changes or duplicate notifications.
|
- [x] A single attack produces no duplicate state changes or duplicate notifications.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-003: Identify protected players"
|
title: "US-003: Identify protected players"
|
||||||
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
|
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-003: Identify protected players
|
# US-003: Identify protected players
|
||||||
@@ -11,14 +11,14 @@ As a **server participant**, I want protected players to be visibly identified s
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] An actively protected player has a configurable leaf prefix before their name in the player list.
|
- [x] An actively protected player has a configurable leaf prefix before their name in the player list.
|
||||||
- [ ] 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 before their overhead name where Spigot supports it.
|
||||||
- [ ] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it.
|
- [x] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it.
|
||||||
- [ ] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting.
|
- [x] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting.
|
||||||
- [ ] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator.
|
- [x] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator.
|
||||||
- [ ] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled.
|
- [x] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled.
|
||||||
- [ ] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist.
|
- [x] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist.
|
||||||
- [ ] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
- [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-004: Introduce new players to Leaf"
|
title: "US-004: Introduce new players to Leaf"
|
||||||
description: Remind players how to control Leaf during their first seven calendar days on the server.
|
description: Remind players how to control Leaf during their first seven calendar days on the server.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-004: Introduce new players to Leaf
|
# US-004: Introduce new players to Leaf
|
||||||
@@ -11,14 +11,14 @@ As a **new player**, I want a brief explanation of Leaf when I join so that I kn
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Leaf records the first observed join time for each player by UUID as an RFC 3339 UTC timestamp.
|
- [x] Leaf records the first observed join time for each player by UUID as an RFC 3339 UTC timestamp.
|
||||||
- [ ] On every login before the first-join timestamp plus seven calendar days, the player receives a concise welcome message explaining Leaf protection.
|
- [x] On every login before the first-join timestamp plus seven calendar days, the player receives a concise welcome message explaining Leaf protection.
|
||||||
- [ ] The welcome message tells the player to use `/leaf on`, `/leaf off`, and `/leaf status`.
|
- [x] The welcome message tells the player to use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||||
- [ ] The message makes clear that attacking another player automatically opts the attacker out.
|
- [x] The message makes clear that attacking another player automatically opts the attacker out.
|
||||||
- [ ] Players no longer receive the welcome message once their seven-day onboarding period expires.
|
- [x] Players no longer receive the welcome message once their seven-day onboarding period expires.
|
||||||
- [ ] Existing first-join timestamps are not reset by logout, restart, opt-in changes, or global disablement.
|
- [x] Existing first-join timestamps are not reset by logout, restart, opt-in changes, or global disablement.
|
||||||
- [ ] No onboarding reminders are shown while Leaf is globally disabled.
|
- [x] No onboarding reminders are shown while Leaf is globally disabled.
|
||||||
- [ ] Re-enabling Leaf allows reminders to resume on subsequent logins only for players whose original seven-day period has not expired.
|
- [x] Re-enabling Leaf allows reminders to resume on subsequent logins only for players whose original seven-day period has not expired.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-005: Administer Leaf"
|
title: "US-005: Administer Leaf"
|
||||||
description: Give administrators command-based control over global and per-player Leaf behavior.
|
description: Give administrators command-based control over global and per-player Leaf behavior.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-005: Administer Leaf
|
# US-005: Administer Leaf
|
||||||
@@ -11,19 +11,19 @@ As a **server administrator**, I want to control Leaf globally and per player so
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Administrative commands require `leaf.admin`, which server operators receive by default.
|
- [x] Administrative commands require `leaf.admin`, which server operators receive by default.
|
||||||
- [ ] `/leaf enabled <on|off>` globally enables or disables Leaf.
|
- [x] `/leaf enabled <on|off>` globally enables or disables Leaf.
|
||||||
- [ ] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices.
|
- [x] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices.
|
||||||
- [ ] Global enablement immediately restores protection and prefixes for eligible online opted-in players.
|
- [x] Global enablement immediately restores protection and prefixes for eligible online opted-in players.
|
||||||
- [ ] `/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> status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time.
|
||||||
- [ ] `/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> enabled <on|off>` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix.
|
||||||
- [ ] `/leaf player <name|uuid> locked <on|off>` controls whether that player can change their own saved choice.
|
- [x] `/leaf player <name|uuid> locked <on|off>` controls whether that player can change their own saved choice.
|
||||||
- [ ] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied.
|
- [x] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied.
|
||||||
- [ ] PvP-triggered opt-out remains authoritative even for a locked player.
|
- [x] PvP-triggered opt-out remains authoritative even for a locked player.
|
||||||
- [ ] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name.
|
- [x] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name.
|
||||||
- [ ] `/leaf strength <1-5>` changes the live Resistance level and immediately updates protected online players.
|
- [x] `/leaf strength <1-5>` changes the live Resistance level and immediately updates protected online players.
|
||||||
- [ ] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position.
|
- [x] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position.
|
||||||
- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-006: Configure and persist Leaf"
|
title: "US-006: Configure and persist Leaf"
|
||||||
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
|
description: Provide validated configuration and durable, defensive storage for Leaf behavior.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-006: Configure and persist Leaf
|
# US-006: Configure and persist Leaf
|
||||||
@@ -11,16 +11,16 @@ As a **server operator**, I want Leaf settings and player state to be validated
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
|
- [x] Configuration supports the global enabled state, Resistance strength, leaf prefix, onboarding duration, and player-facing messages.
|
||||||
- [ ] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
|
- [x] Resistance strength defaults to level I and accepts only Minecraft Resistance levels I through V.
|
||||||
- [ ] Runtime changes made through `/leaf enabled` and `/leaf strength` are persisted for subsequent restarts.
|
- [x] Runtime changes made through `/leaf enabled` and `/leaf strength` are persisted for subsequent restarts.
|
||||||
- [ ] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
|
- [x] UUID-keyed player state persists the latest known name, saved opt-in choice, administrative lock, and first-join timestamp.
|
||||||
- [ ] Date-times use RFC 3339 UTC notation with a `Z` suffix.
|
- [x] Date-times use RFC 3339 UTC notation with a `Z` suffix.
|
||||||
- [ ] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
|
- [x] State is saved safely so that a failed write does not replace valid persisted state with a partial document.
|
||||||
- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||||
- [ ] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
|
- [x] Corrupt or invalid player records are handled defensively and cannot silently grant protection or privileges.
|
||||||
- [ ] 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 does not remove a distinct Resistance effect that Leaf does not own when the API provides enough information to distinguish it.
|
||||||
- [ ] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
- [x] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-007: Build and release Leaf"
|
title: "US-007: Build and release Leaf"
|
||||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||||
status: in-progress
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-007: Build and release Leaf
|
# US-007: Build and release Leaf
|
||||||
@@ -21,7 +21,7 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Spig
|
|||||||
- [x] A successful release builds a versioned Leaf JAR and attaches it to the corresponding Gitea release.
|
- [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] 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] The initial approved user-story bundle is kept in one documentation commit.
|
||||||
- [ ] Subsequent implementation work is organized into a separate conventional commit for each implemented user story.
|
- [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.
|
- [x] No remote push is performed until the maintainer confirms that the Gitea repository and release token secret are ready.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabExecutor;
|
||||||
|
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> ON_OFF = List.of("on", "off");
|
||||||
|
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;
|
||||||
|
|
||||||
|
public LeafCommand(LeafRuntime runtime) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String label,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length == 0) {
|
||||||
|
sendUsage(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String action = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
try {
|
||||||
|
if (PLAYER_COMMANDS.contains(action)) {
|
||||||
|
executePlayer(sender, action, arguments);
|
||||||
|
} else if (ADMIN_COMMANDS.contains(action)) {
|
||||||
|
executeAdmin(sender, action, arguments);
|
||||||
|
} else {
|
||||||
|
sendUsage(sender);
|
||||||
|
}
|
||||||
|
} catch (IOException | IllegalArgumentException exception) {
|
||||||
|
sender.sendMessage("Leaf request failed: " + exception.getMessage() + ". No partial change was applied.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
ArrayList<String> roots = new ArrayList<>();
|
||||||
|
if (sender.hasPermission("leaf.use")) {
|
||||||
|
roots.addAll(PLAYER_COMMANDS);
|
||||||
|
}
|
||||||
|
if (sender.hasPermission("leaf.admin")) {
|
||||||
|
roots.addAll(ADMIN_COMMANDS);
|
||||||
|
}
|
||||||
|
return matching(roots, arguments[0]);
|
||||||
|
}
|
||||||
|
if (!sender.hasPermission("leaf.admin")) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String root = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
return switch (root) {
|
||||||
|
case "enabled" -> matching(ON_OFF, arguments[1]);
|
||||||
|
case "strength" -> matching(LEVELS, arguments[1]);
|
||||||
|
case "player" -> matching(runtime.knownTargets(), arguments[1]);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && root.equals("player")) {
|
||||||
|
return matching(PLAYER_PROPERTIES, arguments[2]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 4 && root.equals("player")
|
||||||
|
&& (arguments[2].equalsIgnoreCase("enabled")
|
||||||
|
|| arguments[2].equalsIgnoreCase("locked"))) {
|
||||||
|
return matching(ON_OFF, arguments[3]);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executePlayer(CommandSender sender, String action, String[] arguments)
|
||||||
|
throws IOException {
|
||||||
|
if (arguments.length != 1) {
|
||||||
|
throw new IllegalArgumentException("Usage: /leaf <on|off|status>");
|
||||||
|
}
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
throw new IllegalArgumentException("this command must be run by a player");
|
||||||
|
}
|
||||||
|
if (!sender.hasPermission("leaf.use")) {
|
||||||
|
throw new IllegalArgumentException("you do not have permission to use Leaf");
|
||||||
|
}
|
||||||
|
switch (action) {
|
||||||
|
case "on" -> reportOwnChoice(player, true, runtime.setOwnChoice(player, true));
|
||||||
|
case "off" -> reportOwnChoice(player, false, runtime.setOwnChoice(player, false));
|
||||||
|
case "status" -> reportStatus(player, player.getUniqueId(), false);
|
||||||
|
default -> throw new IllegalStateException("validated action was not handled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeAdmin(CommandSender sender, String action, String[] arguments)
|
||||||
|
throws IOException {
|
||||||
|
if (!sender.hasPermission("leaf.admin")) {
|
||||||
|
throw new IllegalArgumentException("you do not have permission to administer Leaf");
|
||||||
|
}
|
||||||
|
switch (action) {
|
||||||
|
case "enabled" -> executeGlobalEnabled(sender, arguments);
|
||||||
|
case "strength" -> executeStrength(sender, arguments);
|
||||||
|
case "player" -> executeTargeted(sender, arguments);
|
||||||
|
default -> throw new IllegalStateException("validated action was not handled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeGlobalEnabled(CommandSender sender, String[] arguments) throws IOException {
|
||||||
|
if (arguments.length != 2) {
|
||||||
|
throw new IllegalArgumentException("Usage: /leaf enabled <on|off>");
|
||||||
|
}
|
||||||
|
boolean enabled = parseOnOff(arguments[1]);
|
||||||
|
LeafRuntime.Change change = runtime.setGlobalEnabled(enabled);
|
||||||
|
sender.sendMessage(changeMessage("Global Leaf", enabled, change));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeStrength(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);
|
||||||
|
}
|
||||||
|
LeafRuntime.Change change = runtime.setResistanceLevel(level);
|
||||||
|
sender.sendMessage(
|
||||||
|
change == LeafRuntime.Change.CHANGED
|
||||||
|
? "Leaf Resistance strength changed to " + level + "."
|
||||||
|
: "Leaf Resistance strength was already " + level + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeTargeted(CommandSender sender, String[] arguments) throws IOException {
|
||||||
|
if (arguments.length < 3 || arguments.length > 4) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Usage: /leaf player <name|uuid> <status|enabled|locked> [on|off]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
UUID playerId = runtime.resolveTarget(arguments[1]);
|
||||||
|
String property = arguments[2].toLowerCase(Locale.ROOT);
|
||||||
|
if (property.equals("status") && arguments.length == 3) {
|
||||||
|
reportStatus(sender, playerId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arguments.length != 4 || (!property.equals("enabled") && !property.equals("locked"))) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Usage: /leaf player <name|uuid> <status|enabled|locked> [on|off]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
boolean enabled = parseOnOff(arguments[3]);
|
||||||
|
LeafRuntime.Change change = property.equals("enabled")
|
||||||
|
? runtime.setChoice(playerId, enabled)
|
||||||
|
: runtime.setLocked(playerId, enabled);
|
||||||
|
PlayerLeafState state = runtime.playerState(playerId);
|
||||||
|
String subject = state.latestName() + " (" + playerId + ") " + property;
|
||||||
|
sender.sendMessage(changeMessage(subject, enabled, change));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportOwnChoice(Player player, boolean enabled, LeafRuntime.Change change) {
|
||||||
|
if (change == LeafRuntime.Change.LOCKED) {
|
||||||
|
player.sendMessage(LeafText.color(runtime.settings().lockedMessage()));
|
||||||
|
} else {
|
||||||
|
player.sendMessage(changeMessage("Leaf protection", enabled, change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportStatus(CommandSender sender, UUID playerId, boolean includeIdentity) {
|
||||||
|
LeafRuntime.Status status = runtime.status(playerId);
|
||||||
|
String identity = "";
|
||||||
|
if (includeIdentity) {
|
||||||
|
PlayerLeafState state = runtime.playerState(playerId);
|
||||||
|
identity = state.latestName() + " (" + playerId + ") — ";
|
||||||
|
}
|
||||||
|
sender.sendMessage(
|
||||||
|
identity + "Leaf status — Saved choice: " + onOff(status.savedChoice())
|
||||||
|
+ "; active: " + yesNo(status.activeProtection())
|
||||||
|
+ "; locked: " + yesNo(status.locked())
|
||||||
|
+ "; global: " + onOff(status.globallyEnabled())
|
||||||
|
+ "; first join: " + status.firstJoin() + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String changeMessage(
|
||||||
|
String subject,
|
||||||
|
boolean enabled,
|
||||||
|
LeafRuntime.Change change
|
||||||
|
) {
|
||||||
|
String value = onOff(enabled);
|
||||||
|
return change == LeafRuntime.Change.CHANGED
|
||||||
|
? subject + " changed to " + value + "."
|
||||||
|
: subject + " was already " + value + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean parseOnOff(String value) {
|
||||||
|
if (value.equalsIgnoreCase("on")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value.equalsIgnoreCase("off")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("value must be on or off");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendUsage(CommandSender sender) {
|
||||||
|
sender.sendMessage("Usage: /leaf <on|off|status|enabled|strength|player>");
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> matching(List<String> candidates, String partial) {
|
||||||
|
String normalized = partial.toLowerCase(Locale.ROOT);
|
||||||
|
List<String> matches = new ArrayList<>();
|
||||||
|
for (String candidate : candidates) {
|
||||||
|
if (candidate.toLowerCase(Locale.ROOT).startsWith(normalized)) {
|
||||||
|
matches.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String onOff(boolean value) {
|
||||||
|
return value ? "on" : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String yesNo(boolean value) {
|
||||||
|
return value ? "yes" : "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.ScoreboardManager;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
|
||||||
|
/** Conservatively decorates standard Spigot player identity surfaces. */
|
||||||
|
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> teamEntries = new HashMap<>();
|
||||||
|
|
||||||
|
public LeafIdentity(Server server, Logger logger) {
|
||||||
|
this.server = server;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void apply(Player player, String configuredPrefix) {
|
||||||
|
String prefix = ChatColor.translateAlternateColorCodes('&', 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;
|
||||||
|
|
||||||
|
if (previous == null || player.getDisplayName().equals(previous.decoratedDisplay())) {
|
||||||
|
player.setDisplayName(decoratedDisplay);
|
||||||
|
}
|
||||||
|
if (previous == null || player.getPlayerListName().equals(previous.decoratedList())) {
|
||||||
|
player.setPlayerListName(decoratedList);
|
||||||
|
}
|
||||||
|
names.put(playerId, new Names(baseDisplay, baseList, decoratedDisplay, decoratedList));
|
||||||
|
applyOverhead(player, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void remove(Player player) {
|
||||||
|
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 entry = teamEntries.remove(playerId);
|
||||||
|
Team team = leafTeam(false);
|
||||||
|
if (entry != null && team != null) {
|
||||||
|
team.removeEntry(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyOverhead(Player player, String prefix) {
|
||||||
|
Scoreboard scoreboard = mainScoreboard();
|
||||||
|
if (scoreboard == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String entry = player.getName();
|
||||||
|
Team existing = scoreboard.getEntryTeam(entry);
|
||||||
|
Team leafTeam = leafTeam(true);
|
||||||
|
if (leafTeam == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing != null && existing != leafTeam) {
|
||||||
|
logger.fine("Leaf overhead prefix skipped for " + entry + ": player uses another team");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String oldEntry = teamEntries.put(player.getUniqueId(), entry);
|
||||||
|
if (oldEntry != null && !oldEntry.equals(entry)) {
|
||||||
|
leafTeam.removeEntry(oldEntry);
|
||||||
|
}
|
||||||
|
leafTeam.setPrefix(prefix);
|
||||||
|
leafTeam.addEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Team leafTeam(boolean create) {
|
||||||
|
Scoreboard scoreboard = mainScoreboard();
|
||||||
|
if (scoreboard == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Team team = scoreboard.getTeam(TEAM_NAME);
|
||||||
|
if (team == null && create) {
|
||||||
|
try {
|
||||||
|
team = scoreboard.registerNewTeam(TEAM_NAME);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
logger.warning("Could not create Leaf scoreboard team: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Scoreboard mainScoreboard() {
|
||||||
|
ScoreboardManager manager = server.getScoreboardManager();
|
||||||
|
return manager == null ? null : manager.getMainScoreboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
|
||||||
|
/** Handles player lifecycle events that affect Leaf protection. */
|
||||||
|
public final class LeafListener implements Listener {
|
||||||
|
private final LeafRuntime runtime;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Logger logger;
|
||||||
|
private final PlayerDamageAttributor damageAttributor;
|
||||||
|
|
||||||
|
public LeafListener(LeafRuntime runtime, Clock clock, Logger logger) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
this.clock = clock;
|
||||||
|
this.logger = logger;
|
||||||
|
this.damageAttributor = new PlayerDamageAttributor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
try {
|
||||||
|
runtime.observe(event.getPlayer(), clock.instant());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not persist Leaf player state on join", exception);
|
||||||
|
runtime.removePresentation(event.getPlayer());
|
||||||
|
event.getPlayer().sendMessage("Leaf protection is unavailable because state could not be saved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onQuit(PlayerQuitEvent event) {
|
||||||
|
runtime.removePresentation(event.getPlayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onDamage(EntityDamageEvent event) {
|
||||||
|
if (event.isCancelled() || event.getFinalDamage() <= 0.0
|
||||||
|
|| !(event.getEntity() instanceof Player victim)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<UUID> responsible = damageAttributor.responsiblePlayer(event);
|
||||||
|
if (responsible.isEmpty() || responsible.get().equals(victim.getUniqueId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
runtime.combatOptOut(responsible.get());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not persist Leaf combat opt-out", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record LeafPersistentState(Map<UUID, PlayerLeafState> players) {
|
||||||
|
public LeafPersistentState {
|
||||||
|
players = players == null ? Map.of() : Map.copyOf(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LeafPersistentState empty() {
|
||||||
|
return new LeafPersistentState(Map.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,95 @@
|
|||||||
package games.dmg.leaf;
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import org.bukkit.command.PluginCommand;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
public final class LeafPlugin extends JavaPlugin {
|
public final class LeafPlugin extends JavaPlugin {
|
||||||
|
private LeafSettingsProvider settingsProvider;
|
||||||
|
private LeafStateManager stateManager;
|
||||||
|
private LeafRuntime runtime;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
|
saveDefaultConfig();
|
||||||
|
try {
|
||||||
|
Map<String, Object> values = getConfig().getValues(false);
|
||||||
|
settingsProvider = new LeafSettingsProvider(LeafSettings.from(values));
|
||||||
|
stateManager = new LeafStateManager(
|
||||||
|
new YamlLeafStateRepository(getDataFolder().toPath().resolve("state.yml"))
|
||||||
|
);
|
||||||
|
runtime = new LeafRuntime(
|
||||||
|
getServer(),
|
||||||
|
settingsProvider,
|
||||||
|
stateManager,
|
||||||
|
new LeafProtection(),
|
||||||
|
new LeafIdentity(getServer(), getLogger()),
|
||||||
|
this::persistRuntimeSettings
|
||||||
|
);
|
||||||
|
registerRuntime();
|
||||||
|
} catch (IllegalArgumentException | IOException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Could not initialize Leaf", exception);
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer().getScheduler().runTaskTimer(this, this::saveState, 600L, 600L);
|
||||||
getLogger().info("Leaf enabled.");
|
getLogger().info("Leaf enabled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
if (runtime != null) {
|
||||||
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
|
runtime.removePresentation(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerRuntime() {
|
||||||
|
LeafCommand leafCommand = new LeafCommand(runtime);
|
||||||
|
PluginCommand command = getCommand("leaf");
|
||||||
|
if (command == null) {
|
||||||
|
throw new IllegalStateException("Leaf command is missing from plugin.yml");
|
||||||
|
}
|
||||||
|
command.setExecutor(leafCommand);
|
||||||
|
command.setTabCompleter(leafCommand);
|
||||||
|
getServer().getPluginManager().registerEvents(
|
||||||
|
new LeafListener(runtime, Clock.systemUTC(), getLogger()),
|
||||||
|
this
|
||||||
|
);
|
||||||
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
|
runtime.reconcile(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LeafSettingsProvider settingsProvider() {
|
||||||
|
return settingsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
LeafStateManager stateManager() {
|
||||||
|
return stateManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
void persistRuntimeSettings(LeafSettings settings) {
|
||||||
|
getConfig().set("enabled", settings.enabled());
|
||||||
|
getConfig().set("resistance-level", settings.resistanceLevel());
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveState() {
|
||||||
|
if (stateManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Could not save Leaf player state", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
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. */
|
||||||
|
public final class LeafProtection {
|
||||||
|
private final Map<UUID, PotionEffect> appliedEffects = new HashMap<>();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
if (desired.equals(active) && desired.equals(appliedEffects.get(player.getUniqueId()))) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean isEffective(Player player, int level) {
|
||||||
|
return effectForLevel(level).equals(
|
||||||
|
player.getPotionEffect(PotionEffectType.RESISTANCE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean owns(Player player) {
|
||||||
|
PotionEffect expected = appliedEffects.get(player.getUniqueId());
|
||||||
|
return expected != null
|
||||||
|
&& expected.equals(player.getPotionEffect(PotionEffectType.RESISTANCE));
|
||||||
|
}
|
||||||
|
|
||||||
|
static PotionEffect effectForLevel(int level) {
|
||||||
|
if (level < 1 || level > 5) {
|
||||||
|
throw new IllegalArgumentException("Resistance level must be between 1 and 5");
|
||||||
|
}
|
||||||
|
return new PotionEffect(
|
||||||
|
PotionEffectType.RESISTANCE,
|
||||||
|
PotionEffect.INFINITE_DURATION,
|
||||||
|
level - 1,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeMatching(Player player, PotionEffect expected) {
|
||||||
|
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
if (expected.equals(active)) {
|
||||||
|
player.removePotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Coordinates durable player intent with live Leaf-managed protection. */
|
||||||
|
public final class LeafRuntime {
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SettingsPersistence {
|
||||||
|
void save(LeafSettings settings) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Change {
|
||||||
|
CHANGED,
|
||||||
|
UNCHANGED,
|
||||||
|
LOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Status(
|
||||||
|
boolean savedChoice,
|
||||||
|
boolean activeProtection,
|
||||||
|
boolean locked,
|
||||||
|
boolean globallyEnabled,
|
||||||
|
Instant firstJoin
|
||||||
|
) { }
|
||||||
|
|
||||||
|
private final Server server;
|
||||||
|
private final LeafSettingsProvider settingsProvider;
|
||||||
|
private final LeafStateManager stateManager;
|
||||||
|
private final LeafProtection protection;
|
||||||
|
private final LeafIdentity identity;
|
||||||
|
private final SettingsPersistence settingsPersistence;
|
||||||
|
|
||||||
|
public LeafRuntime(
|
||||||
|
Server server,
|
||||||
|
LeafSettingsProvider settingsProvider,
|
||||||
|
LeafStateManager stateManager,
|
||||||
|
LeafProtection protection,
|
||||||
|
LeafIdentity identity
|
||||||
|
) {
|
||||||
|
this(server, settingsProvider, stateManager, protection, identity, settings -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafRuntime(
|
||||||
|
Server server,
|
||||||
|
LeafSettingsProvider settingsProvider,
|
||||||
|
LeafStateManager stateManager,
|
||||||
|
LeafProtection protection,
|
||||||
|
LeafIdentity identity,
|
||||||
|
SettingsPersistence settingsPersistence
|
||||||
|
) {
|
||||||
|
this.server = Objects.requireNonNull(server, "server");
|
||||||
|
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.protection = Objects.requireNonNull(protection, "protection");
|
||||||
|
this.identity = Objects.requireNonNull(identity, "identity");
|
||||||
|
this.settingsPersistence = Objects.requireNonNull(
|
||||||
|
settingsPersistence,
|
||||||
|
"settingsPersistence"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
|
||||||
|
PlayerLeafState state = stateManager.observePlayer(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
observedAt
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
reconcile(player);
|
||||||
|
LeafSettings settings = settingsProvider.current();
|
||||||
|
Instant onboardingEnd = state.firstJoin().plus(settings.onboardingDays(), ChronoUnit.DAYS);
|
||||||
|
if (settings.enabled() && observedAt.isBefore(onboardingEnd)) {
|
||||||
|
player.sendMessage(LeafText.color(settings.welcomeMessage()));
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setOwnChoice(Player player, boolean optedIn) throws IOException {
|
||||||
|
PlayerLeafState state = requiredState(player.getUniqueId());
|
||||||
|
if (state.locked()) {
|
||||||
|
return Change.LOCKED;
|
||||||
|
}
|
||||||
|
return setChoice(player.getUniqueId(), optedIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setChoice(UUID playerId, boolean optedIn) throws IOException {
|
||||||
|
PlayerLeafState state = requiredState(playerId);
|
||||||
|
if (state.optedIn() == optedIn) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
stateManager.update(playerId, current -> current.withOptedIn(optedIn));
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
Player online = server.getPlayer(playerId);
|
||||||
|
if (online != null) {
|
||||||
|
reconcile(online);
|
||||||
|
}
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change combatOptOut(UUID playerId) throws IOException {
|
||||||
|
PlayerLeafState state = stateManager.find(playerId).orElse(null);
|
||||||
|
if (state == null || !state.optedIn()) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
Change changed = setChoice(playerId, false);
|
||||||
|
Player player = server.getPlayer(playerId);
|
||||||
|
if (changed == Change.CHANGED && player != null) {
|
||||||
|
player.sendMessage(LeafText.color(settingsProvider.current().combatDisabledMessage()));
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setGlobalEnabled(boolean enabled) throws IOException {
|
||||||
|
LeafSettings current = settingsProvider.current();
|
||||||
|
if (current.enabled() == enabled) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
LeafSettings replacement = current.withEnabled(enabled);
|
||||||
|
settingsPersistence.save(replacement);
|
||||||
|
settingsProvider.replace(replacement);
|
||||||
|
reconcileAllOnline();
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setResistanceLevel(int level) throws IOException {
|
||||||
|
LeafSettings current = settingsProvider.current();
|
||||||
|
LeafSettings replacement = current.withResistanceLevel(level);
|
||||||
|
if (current.resistanceLevel() == 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) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
stateManager.update(playerId, current -> current.withLocked(locked));
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Status status(UUID playerId) {
|
||||||
|
PlayerLeafState state = requiredState(playerId);
|
||||||
|
Player online = server.getPlayer(playerId);
|
||||||
|
boolean globallyEnabled = settingsProvider.current().enabled();
|
||||||
|
boolean active = online != null
|
||||||
|
&& globallyEnabled
|
||||||
|
&& state.optedIn()
|
||||||
|
&& protection.isEffective(online, settingsProvider.current().resistanceLevel());
|
||||||
|
return new Status(
|
||||||
|
state.optedIn(),
|
||||||
|
active,
|
||||||
|
state.locked(),
|
||||||
|
globallyEnabled,
|
||||||
|
state.firstJoin()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState playerState(UUID playerId) {
|
||||||
|
return requiredState(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID resolveTarget(String target) {
|
||||||
|
try {
|
||||||
|
UUID playerId = UUID.fromString(target);
|
||||||
|
return stateManager.find(playerId).map(PlayerLeafState::playerId).orElseThrow(
|
||||||
|
() -> new IllegalArgumentException("Unknown Leaf player: " + target)
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException invalidUuidOrUnknown) {
|
||||||
|
Player online = server.getPlayerExact(target);
|
||||||
|
if (online != null && stateManager.find(online.getUniqueId()).isPresent()) {
|
||||||
|
return online.getUniqueId();
|
||||||
|
}
|
||||||
|
List<PlayerLeafState> latestMatches = matchingLatestNames(target);
|
||||||
|
if (latestMatches.size() == 1) {
|
||||||
|
return latestMatches.get(0).playerId();
|
||||||
|
}
|
||||||
|
if (latestMatches.size() > 1) {
|
||||||
|
throw new IllegalArgumentException("Ambiguous player name; use a UUID: " + target);
|
||||||
|
}
|
||||||
|
List<PlayerLeafState> knownMatches = matchingKnownNames(target);
|
||||||
|
if (knownMatches.size() == 1) {
|
||||||
|
return knownMatches.get(0).playerId();
|
||||||
|
}
|
||||||
|
if (knownMatches.size() > 1) {
|
||||||
|
throw new IllegalArgumentException("Ambiguous previous name; use a UUID: " + target);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown Leaf player: " + target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> knownTargets() {
|
||||||
|
return stateManager.players().values().stream()
|
||||||
|
.flatMap(player -> {
|
||||||
|
ArrayList<String> values = new ArrayList<>(player.knownNames());
|
||||||
|
values.add(player.playerId().toString());
|
||||||
|
return values.stream();
|
||||||
|
})
|
||||||
|
.distinct()
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reconcile(Player player) {
|
||||||
|
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())) {
|
||||||
|
identity.apply(player, settings.prefix());
|
||||||
|
} else {
|
||||||
|
identity.remove(player);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removePresentation(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removePresentation(Player player) {
|
||||||
|
protection.remove(player);
|
||||||
|
identity.remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reconcileAllOnline() {
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
reconcile(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafStateManager stateManager() {
|
||||||
|
return stateManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafSettings settings() {
|
||||||
|
return settingsProvider.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PlayerLeafState> matchingLatestNames(String target) {
|
||||||
|
return stateManager.players().values().stream()
|
||||||
|
.filter(player -> player.latestName().equalsIgnoreCase(target))
|
||||||
|
.sorted(Comparator.comparing(player -> player.playerId().toString()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PlayerLeafState> matchingKnownNames(String target) {
|
||||||
|
return stateManager.players().values().stream()
|
||||||
|
.filter(player -> player.knownNames().stream().anyMatch(
|
||||||
|
name -> name.equalsIgnoreCase(target)
|
||||||
|
))
|
||||||
|
.sorted(Comparator.comparing(player -> player.playerId().toString()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerLeafState requiredState(UUID playerId) {
|
||||||
|
return stateManager.find(playerId).orElseThrow(
|
||||||
|
() -> new IllegalArgumentException("unknown player: " + playerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record LeafSettings(
|
||||||
|
boolean enabled,
|
||||||
|
int resistanceLevel,
|
||||||
|
String prefix,
|
||||||
|
int onboardingDays,
|
||||||
|
String welcomeMessage,
|
||||||
|
String combatDisabledMessage,
|
||||||
|
String lockedMessage
|
||||||
|
) {
|
||||||
|
public LeafSettings {
|
||||||
|
if (resistanceLevel < 1 || resistanceLevel > 5) {
|
||||||
|
throw new IllegalArgumentException("resistance-level must be between 1 and 5");
|
||||||
|
}
|
||||||
|
if (onboardingDays <= 0) {
|
||||||
|
throw new IllegalArgumentException("onboarding-days must be positive");
|
||||||
|
}
|
||||||
|
prefix = required(prefix, "prefix");
|
||||||
|
welcomeMessage = required(welcomeMessage, "welcome-message");
|
||||||
|
combatDisabledMessage = required(combatDisabledMessage, "combat-disabled-message");
|
||||||
|
lockedMessage = required(lockedMessage, "locked-message");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LeafSettings from(Map<String, ?> values) {
|
||||||
|
Objects.requireNonNull(values, "values");
|
||||||
|
return new LeafSettings(
|
||||||
|
bool(values, "enabled", true),
|
||||||
|
integer(values, "resistance-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. "
|
||||||
|
+ "Attacking another player opts you out."
|
||||||
|
),
|
||||||
|
string(
|
||||||
|
values,
|
||||||
|
"combat-disabled-message",
|
||||||
|
"&cLeaf protection was disabled because you attacked another player. "
|
||||||
|
+ "You may use /leaf on again when permitted."
|
||||||
|
),
|
||||||
|
string(values, "locked-message", "&cAn administrator locked your Leaf setting.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafSettings withEnabled(boolean newEnabled) {
|
||||||
|
return new LeafSettings(
|
||||||
|
newEnabled,
|
||||||
|
resistanceLevel,
|
||||||
|
prefix,
|
||||||
|
onboardingDays,
|
||||||
|
welcomeMessage,
|
||||||
|
combatDisabledMessage,
|
||||||
|
lockedMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafSettings withResistanceLevel(int level) {
|
||||||
|
return new LeafSettings(
|
||||||
|
enabled,
|
||||||
|
level,
|
||||||
|
prefix,
|
||||||
|
onboardingDays,
|
||||||
|
welcomeMessage,
|
||||||
|
combatDisabledMessage,
|
||||||
|
lockedMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean bool(Map<String, ?> values, String key, boolean defaultValue) {
|
||||||
|
Object value = values.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (!(value instanceof Boolean booleanValue)) {
|
||||||
|
throw new IllegalArgumentException(key + " must be true or false");
|
||||||
|
}
|
||||||
|
return booleanValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int integer(Map<String, ?> values, String key, int defaultValue) {
|
||||||
|
Object value = values.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (!(value instanceof Number number)) {
|
||||||
|
throw new IllegalArgumentException(key + " must be an integer");
|
||||||
|
}
|
||||||
|
double decimal = number.doubleValue();
|
||||||
|
if (!Double.isFinite(decimal) || decimal != Math.rint(decimal)
|
||||||
|
|| decimal > Integer.MAX_VALUE || decimal < Integer.MIN_VALUE) {
|
||||||
|
throw new IllegalArgumentException(key + " must be an integer");
|
||||||
|
}
|
||||||
|
return (int) decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(Map<String, ?> values, String key, String defaultValue) {
|
||||||
|
Object value = values.get(key);
|
||||||
|
return value == null ? defaultValue : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String key) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(key + " must not be blank");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class LeafSettingsProvider {
|
||||||
|
private LeafSettings current;
|
||||||
|
|
||||||
|
public LeafSettingsProvider(LeafSettings initial) {
|
||||||
|
current = Objects.requireNonNull(initial, "initial");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized LeafSettings current() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void replace(LeafSettings replacement) {
|
||||||
|
current = Objects.requireNonNull(replacement, "replacement");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
|
public final class LeafStateManager {
|
||||||
|
private final YamlLeafStateRepository repository;
|
||||||
|
private final Map<UUID, PlayerLeafState> players;
|
||||||
|
private boolean dirty;
|
||||||
|
|
||||||
|
public LeafStateManager(YamlLeafStateRepository repository) throws IOException {
|
||||||
|
this.repository = repository;
|
||||||
|
this.players = new HashMap<>(repository.load().players());
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized PlayerLeafState observePlayer(
|
||||||
|
UUID playerId,
|
||||||
|
String latestName,
|
||||||
|
Instant observedAt
|
||||||
|
) {
|
||||||
|
PlayerLeafState existing = players.get(playerId);
|
||||||
|
PlayerLeafState observed = existing == null
|
||||||
|
? PlayerLeafState.newPlayer(playerId, latestName, observedAt)
|
||||||
|
: existing.withLatestName(latestName);
|
||||||
|
if (!observed.equals(existing)) {
|
||||||
|
players.put(playerId, observed);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
return observed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Optional<PlayerLeafState> find(UUID playerId) {
|
||||||
|
return Optional.ofNullable(players.get(playerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Map<UUID, PlayerLeafState> players() {
|
||||||
|
return Map.copyOf(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized PlayerLeafState update(
|
||||||
|
UUID playerId,
|
||||||
|
UnaryOperator<PlayerLeafState> change
|
||||||
|
) {
|
||||||
|
PlayerLeafState existing = players.get(playerId);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("unknown player: " + playerId);
|
||||||
|
}
|
||||||
|
PlayerLeafState updated = change.apply(existing);
|
||||||
|
if (!playerId.equals(updated.playerId())) {
|
||||||
|
throw new IllegalArgumentException("an update cannot change the player ID");
|
||||||
|
}
|
||||||
|
if (!updated.equals(existing)) {
|
||||||
|
players.put(playerId, updated);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void saveIfDirty() throws IOException {
|
||||||
|
if (!dirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
repository.save(new LeafPersistentState(players));
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
|
||||||
|
final class LeafText {
|
||||||
|
private LeafText() { }
|
||||||
|
|
||||||
|
static String color(String value) {
|
||||||
|
return ChatColor.translateAlternateColorCodes('&', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.damage.DamageSource;
|
||||||
|
import org.bukkit.entity.AreaEffectCloud;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Projectile;
|
||||||
|
import org.bukkit.entity.TNTPrimed;
|
||||||
|
import org.bukkit.entity.Tameable;
|
||||||
|
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.projectiles.ProjectileSource;
|
||||||
|
|
||||||
|
/** Resolves only player attribution that Spigot exposes reliably. */
|
||||||
|
public final class PlayerDamageAttributor {
|
||||||
|
public Optional<UUID> responsiblePlayer(EntityDamageEvent event) {
|
||||||
|
DamageSource source = event.getDamageSource();
|
||||||
|
Optional<UUID> causing = source == null
|
||||||
|
? Optional.empty()
|
||||||
|
: fromEntity(source.getCausingEntity(), 0);
|
||||||
|
if (causing.isPresent()) {
|
||||||
|
return causing;
|
||||||
|
}
|
||||||
|
Optional<UUID> direct = source == null
|
||||||
|
? Optional.empty()
|
||||||
|
: fromEntity(source.getDirectEntity(), 0);
|
||||||
|
if (direct.isPresent()) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
if (event instanceof EntityDamageByEntityEvent byEntity) {
|
||||||
|
return fromEntity(byEntity.getDamager(), 0);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<UUID> fromEntity(Entity entity, int depth) {
|
||||||
|
if (entity == null || depth > 4) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (entity instanceof Player player) {
|
||||||
|
return Optional.of(player.getUniqueId());
|
||||||
|
}
|
||||||
|
if (entity instanceof Tameable tameable && tameable.getOwner() != null) {
|
||||||
|
return Optional.of(tameable.getOwner().getUniqueId());
|
||||||
|
}
|
||||||
|
if (entity instanceof TNTPrimed tnt) {
|
||||||
|
return fromEntity(tnt.getSource(), depth + 1);
|
||||||
|
}
|
||||||
|
if (entity instanceof Projectile projectile) {
|
||||||
|
return fromSource(projectile.getShooter(), depth + 1);
|
||||||
|
}
|
||||||
|
if (entity instanceof AreaEffectCloud cloud) {
|
||||||
|
return fromSource(cloud.getSource(), depth + 1);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<UUID> fromSource(ProjectileSource source, int depth) {
|
||||||
|
if (source instanceof Entity entity) {
|
||||||
|
return fromEntity(entity, depth);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PlayerLeafState(
|
||||||
|
UUID playerId,
|
||||||
|
String latestName,
|
||||||
|
boolean optedIn,
|
||||||
|
boolean locked,
|
||||||
|
Instant firstJoin,
|
||||||
|
Set<String> knownNames
|
||||||
|
) {
|
||||||
|
public PlayerLeafState {
|
||||||
|
if (playerId == null) {
|
||||||
|
throw new IllegalArgumentException("player ID is required");
|
||||||
|
}
|
||||||
|
if (latestName == null || latestName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("latest player name is required");
|
||||||
|
}
|
||||||
|
if (firstJoin == null) {
|
||||||
|
throw new IllegalArgumentException("first join time is required");
|
||||||
|
}
|
||||||
|
HashSet<String> validatedNames = new HashSet<>();
|
||||||
|
if (knownNames != null) {
|
||||||
|
for (String name : knownNames) {
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
validatedNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validatedNames.add(latestName);
|
||||||
|
knownNames = Set.copyOf(validatedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState(
|
||||||
|
UUID playerId,
|
||||||
|
String latestName,
|
||||||
|
boolean optedIn,
|
||||||
|
boolean locked,
|
||||||
|
Instant firstJoin
|
||||||
|
) {
|
||||||
|
this(playerId, latestName, optedIn, locked, firstJoin, Set.of(latestName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PlayerLeafState newPlayer(UUID playerId, String latestName, Instant joinedAt) {
|
||||||
|
return new PlayerLeafState(playerId, latestName, false, false, joinedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState withLatestName(String name) {
|
||||||
|
HashSet<String> names = new HashSet<>(knownNames);
|
||||||
|
names.add(name);
|
||||||
|
return new PlayerLeafState(playerId, name, optedIn, locked, firstJoin, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState withOptedIn(boolean enabled) {
|
||||||
|
return new PlayerLeafState(playerId, latestName, enabled, locked, firstJoin, knownNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState withLocked(boolean newLocked) {
|
||||||
|
return new PlayerLeafState(playerId, latestName, optedIn, newLocked, firstJoin, knownNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
import org.bukkit.configuration.InvalidConfigurationException;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
|
||||||
|
public final class YamlLeafStateRepository {
|
||||||
|
private final Path stateFile;
|
||||||
|
|
||||||
|
public YamlLeafStateRepository(Path stateFile) {
|
||||||
|
this.stateFile = stateFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafPersistentState load() throws IOException {
|
||||||
|
if (!Files.exists(stateFile)) {
|
||||||
|
return LeafPersistentState.empty();
|
||||||
|
}
|
||||||
|
return new LeafPersistentState(loadPlayers(loadYaml(stateFile)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(LeafPersistentState state) throws IOException {
|
||||||
|
Path parent = stateFile.toAbsolutePath().getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
throw new IOException("state file must have a parent directory");
|
||||||
|
}
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
YamlConfiguration yaml = Files.exists(stateFile)
|
||||||
|
? loadYaml(stateFile)
|
||||||
|
: new YamlConfiguration();
|
||||||
|
removeAbsentPlayers(yaml, state.players());
|
||||||
|
savePlayers(yaml, state.players());
|
||||||
|
|
||||||
|
Path temporary = Files.createTempFile(parent, "leaf-state-", ".yml");
|
||||||
|
try {
|
||||||
|
yaml.save(temporary.toFile());
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
temporary,
|
||||||
|
stateFile,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
);
|
||||||
|
} catch (IOException atomicMoveFailure) {
|
||||||
|
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static YamlConfiguration loadYaml(Path path) throws IOException {
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
try {
|
||||||
|
yaml.load(path.toFile());
|
||||||
|
return yaml;
|
||||||
|
} catch (InvalidConfigurationException exception) {
|
||||||
|
throw new IOException("state file is not valid YAML", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<UUID, PlayerLeafState> loadPlayers(YamlConfiguration yaml) {
|
||||||
|
Map<UUID, PlayerLeafState> players = new HashMap<>();
|
||||||
|
ConfigurationSection section = yaml.getConfigurationSection("players");
|
||||||
|
if (section == null) {
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
for (String key : section.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
UUID playerId = UUID.fromString(key);
|
||||||
|
String path = "players." + key;
|
||||||
|
String name = yaml.getString(path + ".name");
|
||||||
|
String firstJoinValue = yaml.getString(path + ".first-join");
|
||||||
|
if (name == null || firstJoinValue == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerLeafState player = new PlayerLeafState(
|
||||||
|
playerId,
|
||||||
|
name,
|
||||||
|
yaml.getBoolean(path + ".opted-in", false),
|
||||||
|
yaml.getBoolean(path + ".locked", false),
|
||||||
|
Instant.parse(firstJoinValue),
|
||||||
|
Set.copyOf(yaml.getStringList(path + ".known-names"))
|
||||||
|
);
|
||||||
|
players.put(playerId, player);
|
||||||
|
} catch (IllegalArgumentException | DateTimeParseException ignored) {
|
||||||
|
// Invalid records are ignored rather than granting protection or privileges.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeAbsentPlayers(
|
||||||
|
YamlConfiguration yaml,
|
||||||
|
Map<UUID, PlayerLeafState> players
|
||||||
|
) {
|
||||||
|
ConfigurationSection section = yaml.getConfigurationSection("players");
|
||||||
|
if (section == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String key : section.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
if (!players.containsKey(UUID.fromString(key))) {
|
||||||
|
yaml.set("players." + key, null);
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
yaml.set("players." + key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void savePlayers(
|
||||||
|
YamlConfiguration yaml,
|
||||||
|
Map<UUID, PlayerLeafState> players
|
||||||
|
) {
|
||||||
|
for (PlayerLeafState player : players.values()) {
|
||||||
|
String path = "players." + player.playerId();
|
||||||
|
yaml.set(path + ".name", player.latestName());
|
||||||
|
yaml.set(path + ".opted-in", player.optedIn());
|
||||||
|
yaml.set(path + ".locked", player.locked());
|
||||||
|
yaml.set(path + ".first-join", player.firstJoin().toString());
|
||||||
|
List<String> knownNames = player.knownNames().stream().sorted().toList();
|
||||||
|
yaml.set(path + ".known-names", knownNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Whether Leaf protection is available server-wide.
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Visible Minecraft Resistance level (I-V).
|
||||||
|
resistance-level: 1
|
||||||
|
|
||||||
|
# Legacy color codes are supported.
|
||||||
|
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."
|
||||||
|
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."
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafCommandTest {
|
||||||
|
@Test
|
||||||
|
void playerCommandsReportChangesLocksAndDetailedStatus() throws Exception {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.hasPermission("leaf.use")).thenReturn(true);
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
when(runtime.settings()).thenReturn(LeafSettings.from(java.util.Map.of()));
|
||||||
|
when(runtime.setOwnChoice(player, true)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
|
when(runtime.setOwnChoice(player, false)).thenReturn(LeafRuntime.Change.LOCKED);
|
||||||
|
when(runtime.status(id)).thenReturn(new LeafRuntime.Status(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Instant.parse("2026-08-10T00:00:00Z")
|
||||||
|
));
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"on"}));
|
||||||
|
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"off"}));
|
||||||
|
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"status"}));
|
||||||
|
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("changed to on"));
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("locked"));
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("Saved choice: on"));
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("active: yes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorCanMutateGlobalStrengthAndTargetedSettings() throws Exception {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
PlayerLeafState state = new PlayerLeafState(
|
||||||
|
targetId,
|
||||||
|
"Alex",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Instant.parse("2026-08-10T00:00:00Z")
|
||||||
|
);
|
||||||
|
when(runtime.resolveTarget("Alex")).thenReturn(targetId);
|
||||||
|
when(runtime.playerState(targetId)).thenReturn(state);
|
||||||
|
when(runtime.setGlobalEnabled(false)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
|
when(runtime.setResistanceLevel(5)).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);
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
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[] {"player", "Alex", "enabled", "on"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(runtime).setGlobalEnabled(false);
|
||||||
|
verify(runtime).setResistanceLevel(5);
|
||||||
|
verify(runtime).setChoice(targetId, true);
|
||||||
|
verify(admin, atLeastOnce()).sendMessage(contains("changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administrativeCompletionIsPermissionAwareAndPositionSpecific() {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
when(runtime.knownTargets()).thenReturn(List.of("Alex", "1234"));
|
||||||
|
CommandSender admin = mock(CommandSender.class);
|
||||||
|
when(admin.hasPermission("leaf.admin")).thenReturn(true);
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("enabled", "strength", "player"),
|
||||||
|
leaf.onTabComplete(admin, command, "leaf", new String[] {""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex"),
|
||||||
|
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "A"})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("status", "enabled", "locked"),
|
||||||
|
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "Alex", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("on", "off"),
|
||||||
|
leaf.onTabComplete(
|
||||||
|
admin,
|
||||||
|
command,
|
||||||
|
"leaf",
|
||||||
|
new String[] {"player", "Alex", "locked", ""}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completionIncludesOnlyPlayerArgumentsAllowedToSender() {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
Player allowed = mock(Player.class);
|
||||||
|
when(allowed.hasPermission("leaf.use")).thenReturn(true);
|
||||||
|
Player denied = mock(Player.class);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("on", "off", "status"),
|
||||||
|
leaf.onTabComplete(allowed, command, "leaf", new String[] {""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
leaf.onTabComplete(denied, command, "leaf", new String[] {""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.ScoreboardManager;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafIdentityTest {
|
||||||
|
@Test
|
||||||
|
void decoratesTabChatAndAnAvailableOverheadTeamThenRestoresNames() {
|
||||||
|
Fixtures fixtures = fixtures();
|
||||||
|
LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger());
|
||||||
|
|
||||||
|
identity.apply(fixtures.player(), "&a🍃 ");
|
||||||
|
|
||||||
|
String decorated = "§a🍃 Alex";
|
||||||
|
verify(fixtures.player()).setDisplayName(decorated);
|
||||||
|
verify(fixtures.player()).setPlayerListName(decorated);
|
||||||
|
verify(fixtures.team()).setPrefix("§a🍃 ");
|
||||||
|
verify(fixtures.team()).addEntry("Alex");
|
||||||
|
|
||||||
|
when(fixtures.player().getDisplayName()).thenReturn(decorated);
|
||||||
|
when(fixtures.player().getPlayerListName()).thenReturn(decorated);
|
||||||
|
identity.remove(fixtures.player());
|
||||||
|
|
||||||
|
verify(fixtures.player()).setDisplayName("Alex");
|
||||||
|
verify(fixtures.player()).setPlayerListName("Alex");
|
||||||
|
verify(fixtures.team()).removeEntry("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leavesThirdPartyChangesAndTeamsUntouched() {
|
||||||
|
Fixtures fixtures = fixtures();
|
||||||
|
Team otherTeam = mock(Team.class);
|
||||||
|
when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam);
|
||||||
|
LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger());
|
||||||
|
identity.apply(fixtures.player(), "&a🍃 ");
|
||||||
|
when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex");
|
||||||
|
when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex");
|
||||||
|
|
||||||
|
identity.remove(fixtures.player());
|
||||||
|
|
||||||
|
verify(otherTeam, never()).removeEntry("Alex");
|
||||||
|
verify(fixtures.player(), never()).setDisplayName("Alex");
|
||||||
|
verify(fixtures.player(), never()).setPlayerListName("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fixtures fixtures() {
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
ScoreboardManager manager = mock(ScoreboardManager.class);
|
||||||
|
Scoreboard scoreboard = mock(Scoreboard.class);
|
||||||
|
Team team = mock(Team.class);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(server.getScoreboardManager()).thenReturn(manager);
|
||||||
|
when(manager.getMainScoreboard()).thenReturn(scoreboard);
|
||||||
|
when(scoreboard.getTeam("leaf_protected")).thenReturn(team);
|
||||||
|
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
when(player.getName()).thenReturn("Alex");
|
||||||
|
when(player.getDisplayName()).thenReturn("Alex");
|
||||||
|
when(player.getPlayerListName()).thenReturn("Alex");
|
||||||
|
return new Fixtures(server, scoreboard, team, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixtures(Server server, Scoreboard scoreboard, Team team, Player player) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.damage.DamageSource;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafListenerCombatTest {
|
||||||
|
@Test
|
||||||
|
void optsOutTheAttackerButNotTheVictim() throws Exception {
|
||||||
|
UUID attackerId = UUID.randomUUID();
|
||||||
|
UUID victimId = UUID.randomUUID();
|
||||||
|
Player attacker = mock(Player.class);
|
||||||
|
Player victim = mock(Player.class);
|
||||||
|
when(attacker.getUniqueId()).thenReturn(attackerId);
|
||||||
|
when(victim.getUniqueId()).thenReturn(victimId);
|
||||||
|
DamageSource source = mock(DamageSource.class);
|
||||||
|
when(source.getCausingEntity()).thenReturn(attacker);
|
||||||
|
EntityDamageEvent event = mock(EntityDamageEvent.class);
|
||||||
|
when(event.getEntity()).thenReturn(victim);
|
||||||
|
when(event.getFinalDamage()).thenReturn(2.0);
|
||||||
|
when(event.getDamageSource()).thenReturn(source);
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
|
||||||
|
new LeafListener(runtime, Clock.systemUTC(), Logger.getAnonymousLogger()).onDamage(event);
|
||||||
|
|
||||||
|
verify(runtime).combatOptOut(attackerId);
|
||||||
|
verify(runtime, never()).combatOptOut(victimId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresCancelledAndUnattributedDamage() throws Exception {
|
||||||
|
Player victim = mock(Player.class);
|
||||||
|
when(victim.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
EntityDamageEvent event = mock(EntityDamageEvent.class);
|
||||||
|
when(event.getEntity()).thenReturn(victim);
|
||||||
|
when(event.getFinalDamage()).thenReturn(2.0);
|
||||||
|
when(event.isCancelled()).thenReturn(true);
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
|
||||||
|
new LeafListener(runtime, Clock.systemUTC(), Logger.getAnonymousLogger()).onDamage(event);
|
||||||
|
|
||||||
|
verify(runtime, never()).combatOptOut(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafListenerLifecycleTest {
|
||||||
|
@Test
|
||||||
|
void removesSessionOwnedPresentationWhenAPlayerQuits() {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
PlayerQuitEvent event = mock(PlayerQuitEvent.class);
|
||||||
|
org.mockito.Mockito.when(event.getPlayer()).thenReturn(player);
|
||||||
|
|
||||||
|
new LeafListener(runtime, Clock.systemUTC(), Logger.getAnonymousLogger()).onQuit(event);
|
||||||
|
|
||||||
|
verify(runtime).removePresentation(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
final class LeafRuntimeTest {
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void playerCanOptInAndProtectionIsRestoredFromPersistedChoice() 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)).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);
|
||||||
|
|
||||||
|
LeafRuntime restarted = runtime(server, protection, stateFile);
|
||||||
|
restarted.reconcile(player);
|
||||||
|
assertTrue(restarted.status(playerId).savedChoice());
|
||||||
|
verify(protection, org.mockito.Mockito.times(2)).apply(player, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void welcomesPlayersOnEachJoinOnlyDuringTheConfiguredWindow() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
mock(LeafProtection.class),
|
||||||
|
temporaryDirectory.resolve("welcome.yml")
|
||||||
|
);
|
||||||
|
Instant firstJoin = Instant.parse("2026-08-01T00:00:00Z");
|
||||||
|
|
||||||
|
runtime.observe(player, firstJoin);
|
||||||
|
runtime.observe(player, firstJoin.plusSeconds(6 * 86_400L));
|
||||||
|
runtime.observe(player, firstJoin.plusSeconds(7 * 86_400L));
|
||||||
|
|
||||||
|
verify(player, times(2)).sendMessage(contains("/leaf on, /leaf off, or /leaf status"));
|
||||||
|
verify(player, times(2)).sendMessage(contains("Attacking another player opts you out"));
|
||||||
|
assertEquals(firstJoin, runtime.status(playerId).firstJoin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void suppressesWelcomeWhileLeafIsGloballyDisabled() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafRuntime runtime = new LeafRuntime(
|
||||||
|
server,
|
||||||
|
new LeafSettingsProvider(LeafSettings.from(Map.of("enabled", false))),
|
||||||
|
new LeafStateManager(new YamlLeafStateRepository(
|
||||||
|
temporaryDirectory.resolve("disabled-welcome.yml")
|
||||||
|
)),
|
||||||
|
mock(LeafProtection.class),
|
||||||
|
mock(LeafIdentity.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-01T00:00:00Z"));
|
||||||
|
|
||||||
|
verify(player, never()).sendMessage(contains("Leaf protection is available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsGlobalSettingsAndImmediatelyReconcilesOnlinePlayers() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
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);
|
||||||
|
LeafIdentity identity = mock(LeafIdentity.class);
|
||||||
|
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
|
||||||
|
ArrayList<LeafSettings> persisted = new ArrayList<>();
|
||||||
|
LeafRuntime runtime = new LeafRuntime(
|
||||||
|
server,
|
||||||
|
settings,
|
||||||
|
new LeafStateManager(new YamlLeafStateRepository(
|
||||||
|
temporaryDirectory.resolve("settings.yml")
|
||||||
|
)),
|
||||||
|
protection,
|
||||||
|
identity,
|
||||||
|
persisted::add
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setChoice(playerId, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(false));
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(true));
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setResistanceLevel(4));
|
||||||
|
|
||||||
|
assertEquals(3, persisted.size());
|
||||||
|
assertFalse(persisted.get(0).enabled());
|
||||||
|
assertEquals(4, settings.current().resistanceLevel());
|
||||||
|
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
|
verify(protection).apply(player, 4);
|
||||||
|
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotActivateASettingWhenItsPersistenceFails() throws Exception {
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
|
||||||
|
LeafRuntime runtime = new LeafRuntime(
|
||||||
|
server,
|
||||||
|
settings,
|
||||||
|
new LeafStateManager(new YamlLeafStateRepository(
|
||||||
|
temporaryDirectory.resolve("failed-settings.yml")
|
||||||
|
)),
|
||||||
|
mock(LeafProtection.class),
|
||||||
|
mock(LeafIdentity.class),
|
||||||
|
replacement -> {
|
||||||
|
throw new java.io.IOException("disk unavailable");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(java.io.IOException.class, () -> runtime.setGlobalEnabled(false));
|
||||||
|
|
||||||
|
assertTrue(settings.current().enabled());
|
||||||
|
verify(server, never()).getOnlinePlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesLatestPreviousNamesAndUuidsWithoutGuessingAmbiguities() throws Exception {
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
mock(LeafProtection.class),
|
||||||
|
temporaryDirectory.resolve("targets.yml")
|
||||||
|
);
|
||||||
|
UUID firstId = UUID.randomUUID();
|
||||||
|
Player first = player(firstId, "Shared");
|
||||||
|
runtime.observe(first, Instant.parse("2026-08-01T00:00:00Z"));
|
||||||
|
when(first.getName()).thenReturn("Alex");
|
||||||
|
runtime.observe(first, Instant.parse("2026-08-02T00:00:00Z"));
|
||||||
|
|
||||||
|
assertEquals(firstId, runtime.resolveTarget("Alex"));
|
||||||
|
assertEquals(firstId, runtime.resolveTarget("Shared"));
|
||||||
|
assertEquals(firstId, runtime.resolveTarget(firstId.toString()));
|
||||||
|
|
||||||
|
UUID secondId = UUID.randomUUID();
|
||||||
|
Player second = player(secondId, "Shared");
|
||||||
|
runtime.observe(second, Instant.parse("2026-08-03T00:00:00Z"));
|
||||||
|
when(second.getName()).thenReturn("Steve");
|
||||||
|
runtime.observe(second, Instant.parse("2026-08-04T00:00:00Z"));
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> runtime.resolveTarget("Shared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockedPlayerCannotChangeTheirChoice() 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);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("locked.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setLocked(playerId, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.LOCKED, runtime.setOwnChoice(player, true));
|
||||||
|
assertFalse(runtime.status(playerId).savedChoice());
|
||||||
|
verify(protection, never()).apply(player, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combatOptOutBypassesLockAndNotifiesExactlyOnce() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
mock(LeafProtection.class),
|
||||||
|
temporaryDirectory.resolve("combat.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setChoice(playerId, true);
|
||||||
|
runtime.setLocked(playerId, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.combatOptOut(playerId));
|
||||||
|
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.combatOptOut(playerId));
|
||||||
|
|
||||||
|
assertFalse(runtime.status(playerId).savedChoice());
|
||||||
|
verify(player).sendMessage(contains("attacked another player"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optingOutRemovesLeafProtectionAndRepeatingIsANoOp() 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);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("off.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setOwnChoice(player, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setOwnChoice(player, false));
|
||||||
|
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.setOwnChoice(player, false));
|
||||||
|
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LeafRuntime runtime(
|
||||||
|
Server server,
|
||||||
|
LeafProtection protection,
|
||||||
|
Path stateFile
|
||||||
|
) throws Exception {
|
||||||
|
return new LeafRuntime(
|
||||||
|
server,
|
||||||
|
new LeafSettingsProvider(LeafSettings.from(Map.of())),
|
||||||
|
new LeafStateManager(new YamlLeafStateRepository(stateFile)),
|
||||||
|
protection,
|
||||||
|
mock(LeafIdentity.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID id, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafSettingsTest {
|
||||||
|
@Test
|
||||||
|
void suppliesSafeDefaults() {
|
||||||
|
LeafSettings settings = LeafSettings.from(Map.of());
|
||||||
|
|
||||||
|
assertEquals(true, settings.enabled());
|
||||||
|
assertEquals(1, settings.resistanceLevel());
|
||||||
|
assertEquals("&a🍃 ", settings.prefix());
|
||||||
|
assertEquals(7, settings.onboardingDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsResistanceOutsideMinecraftLevels() {
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> LeafSettings.from(Map.of("resistance-level", 6))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
final class LeafStateManagerTest {
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsOriginalFirstJoinWhenPlayerReturns() throws Exception {
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
|
||||||
|
LeafStateManager manager = new LeafStateManager(repository);
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Instant first = Instant.parse("2026-08-01T00:00:00Z");
|
||||||
|
|
||||||
|
manager.observePlayer(playerId, "Alex", first);
|
||||||
|
manager.observePlayer(playerId, "AlexNew", Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
manager.saveIfDirty();
|
||||||
|
|
||||||
|
PlayerLeafState saved = repository.load().players().get(playerId);
|
||||||
|
assertEquals(first, saved.firstJoin());
|
||||||
|
assertEquals("AlexNew", saved.latestName());
|
||||||
|
assertEquals(java.util.Set.of("Alex", "AlexNew"), saved.knownNames());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.OfflinePlayer;
|
||||||
|
import org.bukkit.damage.DamageSource;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Projectile;
|
||||||
|
import org.bukkit.entity.Tameable;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class PlayerDamageAttributorTest {
|
||||||
|
@Test
|
||||||
|
void attributesDirectAndThornsDamageToTheCausingPlayer() {
|
||||||
|
Player attacker = player();
|
||||||
|
EntityDamageEvent event = eventWith(attacker, null);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Optional.of(attacker.getUniqueId()),
|
||||||
|
new PlayerDamageAttributor().responsiblePlayer(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesProjectileDamageToItsPlayerShooter() {
|
||||||
|
Player attacker = player();
|
||||||
|
Projectile projectile = mock(Projectile.class);
|
||||||
|
when(projectile.getShooter()).thenReturn(attacker);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Optional.of(attacker.getUniqueId()),
|
||||||
|
new PlayerDamageAttributor().responsiblePlayer(eventWith(null, projectile))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesTamedAnimalDamageToItsOwner() {
|
||||||
|
OfflinePlayer owner = mock(OfflinePlayer.class);
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||||
|
Tameable animal = mock(Tameable.class);
|
||||||
|
when(animal.getOwner()).thenReturn(owner);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Optional.of(ownerId),
|
||||||
|
new PlayerDamageAttributor().responsiblePlayer(eventWith(animal, animal))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leavesAmbiguousEnvironmentalDamageUnattributed() {
|
||||||
|
assertTrue(
|
||||||
|
new PlayerDamageAttributor().responsiblePlayer(eventWith(null, null)).isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EntityDamageEvent eventWith(
|
||||||
|
org.bukkit.entity.Entity causing,
|
||||||
|
org.bukkit.entity.Entity direct
|
||||||
|
) {
|
||||||
|
DamageSource source = mock(DamageSource.class);
|
||||||
|
when(source.getCausingEntity()).thenReturn(causing);
|
||||||
|
when(source.getDirectEntity()).thenReturn(direct);
|
||||||
|
EntityDamageEvent event = mock(EntityDamageEvent.class);
|
||||||
|
when(event.getDamageSource()).thenReturn(source);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player() {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
final class YamlLeafStateRepositoryTest {
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripsPlayerStateWithRfc3339Timestamp() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerLeafState player = new PlayerLeafState(
|
||||||
|
playerId,
|
||||||
|
"Alex",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
Instant.parse("2026-08-10T12:34:56Z")
|
||||||
|
);
|
||||||
|
LeafPersistentState expected = new LeafPersistentState(Map.of(playerId, player));
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
|
||||||
|
|
||||||
|
repository.save(expected);
|
||||||
|
|
||||||
|
assertEquals(expected, repository.load());
|
||||||
|
assertTrue(Files.readString(stateFile).contains("2026-08-10T12:34:56Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresInvalidRecordsRatherThanGrantingProtection() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
players:
|
||||||
|
%s:
|
||||||
|
name: Alex
|
||||||
|
opted-in: true
|
||||||
|
first-join: not-a-timestamp
|
||||||
|
""".formatted(playerId));
|
||||||
|
|
||||||
|
LeafPersistentState loaded = new YamlLeafStateRepository(stateFile).load();
|
||||||
|
|
||||||
|
assertFalse(loaded.players().containsKey(playerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preservesUnknownFieldsWhenSavingKnownPlayers() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
future-root: retained
|
||||||
|
players:
|
||||||
|
%s:
|
||||||
|
name: Alex
|
||||||
|
opted-in: false
|
||||||
|
locked: false
|
||||||
|
first-join: '2026-08-10T12:34:56Z'
|
||||||
|
future-player-field: retained
|
||||||
|
""".formatted(playerId));
|
||||||
|
YamlLeafStateRepository repository = new YamlLeafStateRepository(stateFile);
|
||||||
|
|
||||||
|
repository.save(repository.load());
|
||||||
|
|
||||||
|
String saved = Files.readString(stateFile);
|
||||||
|
assertTrue(saved.contains("future-root: retained"));
|
||||||
|
assertTrue(saved.contains("future-player-field: retained"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user