Compare commits
15
Commits
12a8e84535
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfc1b71af | ||
|
|
f26b7d0f0d | ||
|
|
e7a5a14529 | ||
|
|
904faf1832 | ||
|
|
2e6fdb4e5b | ||
|
|
5d1577175f | ||
|
|
d57f8cc6dd | ||
|
|
206096efc2 | ||
|
|
2664f7c68b | ||
|
|
8c7c008ca0 | ||
|
|
17f1631b96 | ||
|
|
87452e4b87 | ||
|
|
9600520e14 | ||
|
|
486780c049 | ||
|
|
8067b3ebfb |
@@ -1,4 +1,5 @@
|
||||
.gradle/
|
||||
.docker/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Tree Feller
|
||||
|
||||
Tree Feller is a Spigot 26.2 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family.
|
||||
|
||||
The approved behavior and implementation record are in the [OKF design bundle](design/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spigot 26.2
|
||||
- Java 17 or newer
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./gradlew clean check jar
|
||||
```
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
## Local Docker test server
|
||||
|
||||
Build the current plugin and start it on an isolated Spigot 26.2 server:
|
||||
|
||||
```bash
|
||||
./scripts/start-test-server.sh
|
||||
```
|
||||
|
||||
Connect to `localhost:25565` as `WindMagi`, which the harness configures as a level-4 operator using its deterministic offline-mode UUID. The committed `docker/test-ops.json` fixture replaces local operator state on every startup so the offline UUID remains deterministic. The server uses offline mode for local convenience and binds only to the loopback interface.
|
||||
|
||||
The first startup may take several minutes while the image downloads and Spigot is prepared. Override the 15-minute wait or local RCON password when needed:
|
||||
|
||||
```bash
|
||||
TREE_FELLER_START_TIMEOUT=1200 \
|
||||
TREE_FELLER_RCON_PASSWORD='local-secret' \
|
||||
./scripts/start-test-server.sh
|
||||
```
|
||||
|
||||
Common lifecycle commands:
|
||||
|
||||
```bash
|
||||
# Follow server logs
|
||||
docker compose -f compose.test.yml logs -f minecraft
|
||||
|
||||
# Stop while preserving the world and server configuration
|
||||
docker compose -f compose.test.yml down
|
||||
|
||||
# Stop and permanently reset all generated local test state
|
||||
docker compose -f compose.test.yml down
|
||||
rm -rf .docker/minecraft
|
||||
```
|
||||
|
||||
Re-run the start script after code changes to rebuild the JAR and recreate the test container. Generated server state remains under `.docker/minecraft/` and is ignored by Git.
|
||||
|
||||
## Player commands
|
||||
|
||||
```text
|
||||
/treefeller enabled [on|off]
|
||||
/treefeller unlocked
|
||||
/treefeller undo
|
||||
```
|
||||
|
||||
Sneaking while breaking the initiating trunk block bypasses automatic felling. Undo restores only the latest eligible felling, requires all replacement trunk materials in the player's inventory, and defaults to a six-minute window.
|
||||
|
||||
## Administrative commands
|
||||
|
||||
```text
|
||||
/treefelleradmin player <name|uuid> status
|
||||
/treefelleradmin player <name|uuid> tree <type> <grant|reset>
|
||||
/treefelleradmin player <name|uuid> locked <on|off>
|
||||
/treefelleradmin threshold <type> <blocks>
|
||||
```
|
||||
|
||||
Administrative commands require `treefeller.admin`, granted to server operators by default. Player commands and undo use separate non-administrative permissions.
|
||||
|
||||
## Supported species
|
||||
|
||||
- Oak, spruce, birch, jungle, acacia, and dark oak
|
||||
- Mangrove, cherry, and pale oak
|
||||
- Crimson and warped fungi
|
||||
- Giant red and brown mushrooms
|
||||
|
||||
Azalea-grown logs count as oak. Unlock progress counts unstripped natural log and Nether stem materials even after the surrounding tree has been disrupted; wood, hyphae, stripped variants, and bamboo do not count. Player-placed qualifying materials are indistinguishable from generated materials in Spigot and therefore also count. Giant mushroom stems require enough cap context to identify their species.
|
||||
|
||||
## Configuration and state
|
||||
|
||||
`config.yml` controls per-species thresholds, animation delay, bounded search limits, boss-bar presentation, undo duration, unlock titles, and messages. Defaults include 100 manually mined qualifying blocks per species, two ticks between animated blocks, five seconds of progress visibility, and six minutes for undo.
|
||||
|
||||
Player preferences, administrative locks, progress, unlocks, and known names are stored by UUID in `players.yml`. Undo records are intentionally runtime-only and do not survive restart.
|
||||
|
||||
## Safety and compatibility
|
||||
|
||||
Tree detection searches matching natural trunk blocks laterally and upward, never below the initiating chop, and requires corresponding foliage or caps. Search bounds prevent unbounded traversal. Leaves, caps, roots, vines, and decorations are not automatically broken.
|
||||
|
||||
Automatic felling supports Spigot 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use Spigot's `Player.breakBlock` path so block-break cancellation, drops, experience, enchantments, and axe durability remain authoritative. Protection plugins should cancel `BlockBreakEvent` normally. A cancelled additional break stops the remaining felling.
|
||||
|
||||
## Releases
|
||||
|
||||
Gitea Actions checks pushes and pull requests, validates pull-request conventional commits, and stores development JARs. Conventional commits on `main` drive semantic releases after the repository defines a contents-write `RELEASE_TOKEN`.
|
||||
@@ -0,0 +1,34 @@
|
||||
name: tree-feller-test
|
||||
|
||||
services:
|
||||
minecraft:
|
||||
image: itzg/minecraft-server:java25
|
||||
container_name: tree-feller-test
|
||||
ports:
|
||||
- "127.0.0.1:25565:25565"
|
||||
environment:
|
||||
EULA: "TRUE"
|
||||
TYPE: SPIGOT
|
||||
VERSION: "26.2"
|
||||
MEMORY: 2G
|
||||
ONLINE_MODE: "false"
|
||||
OPS_FILE: /config/tree-feller-ops.json
|
||||
OVERRIDE_OPS: "true"
|
||||
ENABLE_RCON: "true"
|
||||
RCON_PASSWORD: "${TREE_FELLER_RCON_PASSWORD:-tree-feller-local-test}"
|
||||
VIEW_DISTANCE: "6"
|
||||
SIMULATION_DISTANCE: "6"
|
||||
SPAWN_PROTECTION: "0"
|
||||
volumes:
|
||||
- ./.docker/minecraft:/data
|
||||
- ./build/libs/tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro
|
||||
- ./docker/test-ops.json:/config/tree-feller-ops.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mc-health"]
|
||||
start_period: 15m
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 90
|
||||
restart: "no"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
+123
-19
@@ -2,6 +2,129 @@
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
### US-003 copper axe support corrected
|
||||
|
||||
- Reproduced copper axes failing automatic-felling eligibility because the explicit Spigot 26.2 axe-material set omitted `COPPER_AXE`.
|
||||
- Added copper axes alongside wooden, stone, iron, golden, diamond, and netherite axes without broadening eligibility to pickaxes or other tools.
|
||||
- Verified copper-axe classification, end-to-end felling activation, non-axe rejection, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-001 disrupted-tree progress corrected
|
||||
|
||||
- Reproduced remaining lower logs failing to count after an upper trunk block was removed because progression unnecessarily re-ran full intact-tree validation.
|
||||
- Progress now classifies unstripped natural `*_LOG`, crimson stem, and warped stem materials directly, so each qualifying block counts even after the tree structure is disrupted.
|
||||
- Wood, hyphae, stripped variants, and bamboo remain excluded; player-placed qualifying materials count because Spigot does not expose generation provenance.
|
||||
- Giant mushroom stems retain cap-based validation because their shared stem material cannot distinguish red from brown, while automatic felling still requires a validated intact tree and an axe.
|
||||
- Verified disrupted-tree progress, excluded processed materials, tool-independent progress, axe-only felling, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-001 tool-independent progress corrected
|
||||
|
||||
- Reproduced the progression gap caused by sharing the automatic-felling axe restriction with manual unlock progress.
|
||||
- Qualifying validated Survival tree blocks now add progress when broken with an axe, another tool, or an empty hand; cancelled, Creative, and automatically felled blocks remain excluded.
|
||||
- Automatic felling remains strictly axe-only, including for players who have already unlocked the species.
|
||||
- Verified empty-hand and pickaxe progress, automatic-break suppression, axe-only felling, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-010 offline operator identity corrected
|
||||
|
||||
- Reproduced WindMagi's missing operator privileges and found that name-based image setup stored the online account UUID while offline-mode login assigned deterministic UUID `6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e`.
|
||||
- Replaced name-based setup with a synchronized, committed operator fixture containing the exact offline UUID; synchronization intentionally makes WindMagi the sole operator in this isolated test harness.
|
||||
- Removed the misleading post-start `op WindMagi` check and made startup verify the exact name and UUID in the generated `ops.json`.
|
||||
- Live restart verification passed: Spigot was healthy, RCON reported TreeFeller, `ops.json` contained only WindMagi at level 4 with the offline UUID, and the server remains available at `localhost:25565`.
|
||||
|
||||
### US-010 local Docker test server completed
|
||||
|
||||
- Added a repository-local Docker Compose harness using `itzg/minecraft-server:java25` with Spigot 26.2, persistent ignored state, offline local testing, and loopback-only port binding.
|
||||
- Added one executable script that runs the strict Gradle build, recreates the isolated container with the latest JAR, waits for health, verifies Tree Feller through RCON, and confirms `WindMagi` as an operator.
|
||||
- Documented start, log, stop, persistent-state reset, timeout, and RCON-password controls.
|
||||
- Live verification completed in 23 seconds: the container was healthy, RCON reported `TreeFeller`, `ops.json` contained `WindMagi` at level 4, and port 25565 was bound only to `127.0.0.1`.
|
||||
- The test server remains running at `localhost:25565` for gameplay testing.
|
||||
|
||||
### US-009 build and release completed
|
||||
|
||||
- Added maintainer documentation for requirements, builds, commands, configuration, supported species, persistence, safety, compatibility, and releases.
|
||||
- Verified workflow YAML, pull-request conventional-commit validation, development artifacts, semantic versioning, and Gitea release attachment configuration against `../spigot-getgud/`.
|
||||
- Verified the complete strict test lifecycle and a release-version build with `./gradlew clean check jar -PreleaseVersion=1.2.3`, producing `build/libs/tree-feller-1.2.3.jar`.
|
||||
- Verified every user story is done, the OKF bundle conforms, the repository has no remote, and no push or release was attempted.
|
||||
|
||||
### US-006 unlock announcements completed
|
||||
|
||||
- Verified that earned unlocks alone produce the configurable achievement title and safety guidance exactly once per earned cycle.
|
||||
- Administrative grants now send a distinct online chat notification without presenting a mined-block achievement or title.
|
||||
- Verified reset-and-reearn behavior, invalid-event suppression, administrative distinction, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-007 administration completed
|
||||
|
||||
- Added the dedicated permission-gated `/treefelleradmin` tree for player status, species grants and resets, global player felling locks, and persistent per-species thresholds.
|
||||
- Added exact live-name, durable-alias, and UUID target resolution with ambiguity rejection and UUID authority.
|
||||
- Added positional, permission-aware completion for roots, known players, properties, species identifiers, actions, and boolean values.
|
||||
- Administrative mutations are idempotent, persist before reporting success, preserve unrelated state, and notify online targets without using earned-achievement titles.
|
||||
- Administratively locked players continue manual progress, receive a configurable explanation when felling is suppressed, and retain their preference and unlocks.
|
||||
- Verified grants, resets, locks, thresholds, identity safety, online messaging, autocomplete, authorization, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-006 unlock announcement checkpoint
|
||||
|
||||
- Added configurable, placeholder-aware titles, subtitles, timing, and chat guidance for newly earned species.
|
||||
- Progress presentation now removes the completed boss bar before showing the one-time achievement and guidance about sneaking and undo.
|
||||
- Verified earned and ordinary progress paths plus the complete build with `./gradlew clean check jar`.
|
||||
- US-006 remains in progress until US-007 verifies distinct online messaging for administrative grants.
|
||||
|
||||
### US-005 safe undo completed
|
||||
|
||||
- Added one runtime-only latest-felling record per player with original world, coordinates, material, block-data string, and completion time.
|
||||
- Added `/treefeller undo` behind the separate `treefeller.undo` permission and the configurable six-minute default window.
|
||||
- Undo now preflights worlds, loaded chunks, empty target positions, exact block data, and aggregate inventory materials before making any change.
|
||||
- Missing-material failures report every material and quantity; successful undo withdraws materials once, restores orientation-aware block data without physics, and consumes the record.
|
||||
- Unexpected inventory or world failures roll back changed blocks and inventory where possible and preserve the record while logging suppressed recovery failures for administrators.
|
||||
- Verified shortages, occupied locations, expiration, successful restoration, command reporting, permissions, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-003 animated tree felling completed
|
||||
|
||||
- Added eligibility-aware automatic felling for unlocked species with sneaking, saved preference, and administrative-lock bypasses.
|
||||
- Added deterministic bottom-to-top scheduling at the configured interval, per-player and per-block overlap claims, and exact runtime snapshots of successfully removed blocks.
|
||||
- Routed each additional trunk through `Player.breakBlock` so Spigot protection cancellation, drops, experience, enchantments, axe durability, and tool breakage remain authoritative.
|
||||
- Felling now stops on cancellation, tool loss, state invalidation, logout, world unload, plugin disablement, changed blocks, or unloaded chunks and releases every runtime claim.
|
||||
- Verified ordering, delay, overlap rejection, cancellation, eligibility, lifecycle safety, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-002 progress visibility completed
|
||||
|
||||
- Added `/treefeller unlocked` with every species' durable unlocked state or current count and live threshold.
|
||||
- Added one configurable boss bar per player with species and numeric progress, live threshold evaluation, timeout replacement, and five-second default cleanup.
|
||||
- Suppressed progress presentation for all ineligible events and removed it immediately when a species unlocks.
|
||||
- Verified command output, player-only completion, boss-bar presentation and timeout, cleanup, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-004 personal controls completed
|
||||
|
||||
- Added `/treefeller enabled [on|off]` with durable, idempotent preference changes and administrative-override reporting.
|
||||
- Added player-only usage and positional completion for `enabled`, `unlocked`, `undo`, and boolean values without exposing the administrative command tree.
|
||||
- Registered configurable player messages and kept `treefeller.command` separate from `treefeller.admin`.
|
||||
- Verified state preservation, reporting, autocomplete, metadata, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-001 species unlock progression completed
|
||||
|
||||
- Added stable taxonomy for all approved overworld trees, Nether fungi, and giant mushrooms while excluding bamboo and treating azalea logs as oak.
|
||||
- Replaced the old recursive search with a deterministic, bounded, iterative scanner that follows connected trunk blocks laterally and upward but never downward and requires matching foliage or caps.
|
||||
- Added Survival-and-axe eligibility, automatic-break suppression, durable one-point increments, saturating counters, permanent unlocks, and next-qualifying-block threshold evaluation.
|
||||
- Registered progress handling through the Spigot block-break lifecycle and persisted every accepted update before notifying observers.
|
||||
- Verified taxonomy, detection, tools, eligibility, progression, persistence, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-008 configuration and persistence completed
|
||||
|
||||
- Added validated settings for all supported species, search safety, animation, progress presentation, undo, titles, and messages.
|
||||
- Added persistence-before-activation threshold changes and failure-safe active settings.
|
||||
- Added UUID-keyed immutable player state with retained names, saturating progress, unlocks, preferences, locks, defensive reads, forward-field retention, and atomic YAML replacement.
|
||||
- Invalid required configuration now disables partial plugin startup with a focused log message.
|
||||
- Verified settings, persistence, corruption handling, atomic replacement, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### Build foundation checkpoint
|
||||
|
||||
- Added the Java 17 Gradle project, Spigot 26.2 dependency, strict compilation, JUnit lifecycle, plugin metadata, wrapper, and Gitea CI and semantic-release workflows.
|
||||
- Verified separate player and administrative command metadata through a failing-then-passing test.
|
||||
- Verified the local foundation with `./gradlew clean check jar`; remote workflow and release criteria remain pending final delivery verification.
|
||||
|
||||
### Implementation started
|
||||
|
||||
- Began US-009 by establishing the test-first Gradle and plugin-metadata foundation.
|
||||
- US-009 remains in progress until all stories and final delivery behavior have been verified.
|
||||
|
||||
### Initial tree-felling design
|
||||
|
||||
- Players earn automatic felling separately for each supported tree species by manually mining qualifying tree blocks in Survival with an axe.
|
||||
@@ -15,22 +138,3 @@
|
||||
- Player commands use `/treefeller`; administration uses the separate `/treefelleradmin` command and `treefeller.admin` permission.
|
||||
- Administrators can inspect players, grant or reset species, lock all automatic felling for a player, and persistently change species thresholds. Administrative locking does not stop manual progress.
|
||||
- Build and release conventions follow `../spigot-getgud/`, including Spigot API `26.2-R0.1-SNAPSHOT`, Java 17, Gradle, strict compilation, tests, and Gitea automation.
|
||||
|
||||
### Implementation started
|
||||
|
||||
- Began US-009 by establishing the test-first Gradle and plugin-metadata foundation.
|
||||
- US-009 remains in progress until all stories and final delivery behavior have been verified.
|
||||
|
||||
### Build foundation checkpoint
|
||||
|
||||
- Added the Java 17 Gradle project, Spigot 26.2 dependency, strict compilation, JUnit lifecycle, plugin metadata, wrapper, and Gitea CI and semantic-release workflows.
|
||||
- Verified separate player and administrative command metadata through a failing-then-passing test.
|
||||
- Verified the local foundation with `./gradlew clean check jar`; remote workflow and release criteria remain pending final delivery verification.
|
||||
|
||||
### US-008 configuration and persistence completed
|
||||
|
||||
- Added validated settings for all supported species, search safety, animation, progress presentation, undo, titles, and messages.
|
||||
- Added persistence-before-activation threshold changes and failure-safe active settings.
|
||||
- Added UUID-keyed immutable player state with retained names, saturating progress, unlocks, preferences, locks, defensive reads, forward-field retention, and atomic YAML replacement.
|
||||
- Invalid required configuration now disables partial plugin startup with a focused log message.
|
||||
- Verified settings, persistence, corruption handling, atomic replacement, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
7. [US-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
8. [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
9. [US-009: Build and release Tree Feller](us-009-build-and-release-tree-feller.md)
|
||||
10. [US-010: Run a local Docker test server](us-010-run-local-docker-test-server.md)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-001: Earn tree-type unlocks"
|
||||
description: Let players earn permanent automatic felling separately for each supported tree species.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Earn tree-type unlocks
|
||||
@@ -11,17 +11,21 @@ As a **survival player**, I want to unlock Tree Feller by practicing with each t
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Progress is tracked independently for oak, spruce, birch, jungle, acacia, dark oak, mangrove, cherry, pale oak, crimson fungi, warped fungi, giant red mushrooms, and giant brown mushrooms as represented by Spigot 26.2 materials.
|
||||
- [ ] Azalea-grown oak logs contribute to oak progress, and bamboo does not contribute to any tree species.
|
||||
- [ ] A block contributes progress only when a player manually breaks it in Survival mode, with an axe, from a structure that passes Tree Feller's tree validation.
|
||||
- [ ] Creative-mode breaks, non-axe breaks, cancelled breaks, and blocks removed by automatic felling do not contribute progress.
|
||||
- [ ] Each qualifying manually mined block contributes exactly one point to its species.
|
||||
- [ ] Each species has an independently configurable unlock threshold that defaults to 100 blocks.
|
||||
- [ ] Reaching the active threshold permanently unlocks automatic felling for that species.
|
||||
- [ ] A threshold lowered below a player's saved progress grants the unlock when that player next mines a qualifying block of the species, not immediately when configuration changes.
|
||||
- [ ] Raising a threshold never removes an earned unlock.
|
||||
- [ ] A player whose automatic felling is disabled or administratively locked may continue earning progress through qualifying manual mining.
|
||||
- [ ] Progress and unlocks survive logout and server restart.
|
||||
- [x] Progress is tracked independently for oak, spruce, birch, jungle, acacia, dark oak, mangrove, cherry, pale oak, crimson fungi, warped fungi, giant red mushrooms, and giant brown mushrooms as represented by Spigot 26.2 materials.
|
||||
- [x] Azalea-grown oak logs contribute to oak progress, and bamboo does not contribute to any tree species.
|
||||
- [x] A manually broken unstripped natural trunk material with an unambiguous species contributes Survival progress even when the remaining structure no longer passes full tree validation.
|
||||
- [x] Natural trunk materials include the supported `*_LOG`, `CRIMSON_STEM`, and `WARPED_STEM` blocks; wood, hyphae, stripped variants, and bamboo do not contribute.
|
||||
- [x] Giant mushroom stems contribute only when surrounding cap context allows Tree Feller to distinguish red from brown.
|
||||
- [x] Player-placed blocks using an otherwise qualifying natural trunk material contribute because Spigot does not expose reliable generation provenance.
|
||||
- [x] A qualifying block contributes regardless of whether the player uses an axe, another tool, or an empty hand.
|
||||
- [x] Creative-mode breaks, cancelled breaks, and blocks removed by automatic felling do not contribute progress.
|
||||
- [x] Each qualifying manually mined block contributes exactly one point to its species.
|
||||
- [x] Each species has an independently configurable unlock threshold that defaults to 100 blocks.
|
||||
- [x] Reaching the active threshold permanently unlocks automatic felling for that species.
|
||||
- [x] A threshold lowered below a player's saved progress grants the unlock when that player next mines a qualifying block of the species, not immediately when configuration changes.
|
||||
- [x] Raising a threshold never removes an earned unlock.
|
||||
- [x] A player whose automatic felling is disabled or administratively locked may continue earning progress through qualifying manual mining.
|
||||
- [x] Progress and unlocks survive logout and server restart.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-002: View tree progress"
|
||||
description: Show players which species are unlocked and their progress toward the remaining unlocks.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: View tree progress
|
||||
@@ -11,16 +11,16 @@ As a **player**, I want clear unlock and progress information so that I know whi
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/treefeller unlocked` lists every supported species in a readable locked or unlocked state.
|
||||
- [ ] Each locked species includes the player's current qualifying-block count and active threshold.
|
||||
- [ ] Each unlocked species is clearly distinguished and is not presented as needing further progress.
|
||||
- [ ] Mining a qualifying block for a locked species displays or updates a boss bar with the species name and numeric progress toward its active threshold.
|
||||
- [ ] The boss bar reflects a changed threshold the next time qualifying progress is recorded.
|
||||
- [ ] The boss bar disappears after no qualifying block has been mined for five seconds by default.
|
||||
- [ ] Mining another qualifying block before timeout restarts the configured visibility period.
|
||||
- [ ] Boss-bar visibility duration, text, color, and style are configurable.
|
||||
- [ ] Progress feedback is not displayed for cancelled, ineligible, automatically felled, or already-unlocked blocks.
|
||||
- [ ] `/treefeller unlocked` and its autocomplete expose no administrative functionality.
|
||||
- [x] `/treefeller unlocked` lists every supported species in a readable locked or unlocked state.
|
||||
- [x] Each locked species includes the player's current qualifying-block count and active threshold.
|
||||
- [x] Each unlocked species is clearly distinguished and is not presented as needing further progress.
|
||||
- [x] Mining a qualifying block for a locked species displays or updates a boss bar with the species name and numeric progress toward its active threshold.
|
||||
- [x] The boss bar reflects a changed threshold the next time qualifying progress is recorded.
|
||||
- [x] The boss bar disappears after no qualifying block has been mined for five seconds by default.
|
||||
- [x] Mining another qualifying block before timeout restarts the configured visibility period.
|
||||
- [x] Boss-bar visibility duration, text, color, and style are configurable.
|
||||
- [x] Progress feedback is not displayed for cancelled, ineligible, automatically felled, or already-unlocked blocks.
|
||||
- [x] `/treefeller unlocked` and its autocomplete expose no administrative functionality.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-003: Fell unlocked trees"
|
||||
description: Safely and visibly break an unlocked tree's trunk from the mined block upward.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Fell unlocked trees
|
||||
@@ -11,22 +11,22 @@ As a **player with an unlocked species**, I want its trees to break progressivel
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Automatic felling is considered only for a non-cancelled Survival-mode block break made with an axe against a species the player has unlocked.
|
||||
- [ ] Sneaking when the initiating block is broken always bypasses automatic felling and leaves the ordinary single-block break intact.
|
||||
- [ ] A disabled or administratively locked player receives the ordinary single-block break without automatic felling.
|
||||
- [ ] Tree detection follows connected blocks of the initiating trunk family laterally and upward, including diagonal branches, but never follows trunk blocks below the initiating block.
|
||||
- [ ] Detection requires foliage, wart blocks, or mushroom caps appropriate to the candidate species so that an unsupported log structure is not automatically felled.
|
||||
- [ ] Detection is iterative and bounded by configurable block and search-distance limits; reaching a safety limit aborts automatic felling without preventing the initiating ordinary break.
|
||||
- [ ] Neighbor visitation is deterministic and does not process a location more than once.
|
||||
- [ ] Only trunk, stem, or mushroom-stem blocks are felled; leaves, wart blocks, mushroom caps, roots, vines, and decorations remain for normal game behavior.
|
||||
- [ ] The initiating block is handled by the original break, and remaining discovered trunk blocks break bottom-to-top at a configurable delay of two server ticks per block by default.
|
||||
- [ ] Each additional block is checked through the applicable Bukkit block-break event path, and a cancellation prevents that block and any unsafe continuation from being broken.
|
||||
- [ ] Each successfully felled block produces drops and experience according to its block state, the active axe, enchantments, and the Spigot API rather than duplicating the initiating block's drops.
|
||||
- [ ] Axe durability, including Unbreaking behavior, is applied for every successfully felled block without double-charging the initiating break.
|
||||
- [ ] Felling stops safely before another block is processed when the axe breaks, is removed, or is no longer an eligible axe.
|
||||
- [ ] Logging out, plugin disablement, world unload, or another invalidated runtime condition cancels the remaining animation without breaking queued blocks.
|
||||
- [ ] A player cannot start overlapping automatic fellings that could double-break or double-drop the same blocks.
|
||||
- [ ] Only blocks actually removed by this felling are recorded for undo.
|
||||
- [x] Automatic felling is considered only for a non-cancelled Survival-mode block break made with a supported wooden, stone, copper, iron, golden, diamond, or netherite axe against a species the player has unlocked.
|
||||
- [x] Sneaking when the initiating block is broken always bypasses automatic felling and leaves the ordinary single-block break intact.
|
||||
- [x] A disabled or administratively locked player receives the ordinary single-block break without automatic felling.
|
||||
- [x] Tree detection follows connected blocks of the initiating trunk family laterally and upward, including diagonal branches, but never follows trunk blocks below the initiating block.
|
||||
- [x] Detection requires foliage, wart blocks, or mushroom caps appropriate to the candidate species so that an unsupported log structure is not automatically felled.
|
||||
- [x] Detection is iterative and bounded by configurable block and search-distance limits; reaching a safety limit aborts automatic felling without preventing the initiating ordinary break.
|
||||
- [x] Neighbor visitation is deterministic and does not process a location more than once.
|
||||
- [x] Only trunk, stem, or mushroom-stem blocks are felled; leaves, wart blocks, mushroom caps, roots, vines, and decorations remain for normal game behavior.
|
||||
- [x] The initiating block is handled by the original break, and remaining discovered trunk blocks break bottom-to-top at a configurable delay of two server ticks per block by default.
|
||||
- [x] Each additional block is checked through the applicable Bukkit block-break event path, and a cancellation prevents that block and any unsafe continuation from being broken.
|
||||
- [x] Each successfully felled block produces drops and experience according to its block state, the active axe, enchantments, and the Spigot API rather than duplicating the initiating block's drops.
|
||||
- [x] Axe durability, including Unbreaking behavior, is applied for every successfully felled block without double-charging the initiating break.
|
||||
- [x] Felling stops safely before another block is processed when the axe breaks, is removed, or is no longer an eligible axe.
|
||||
- [x] Logging out, plugin disablement, world unload, or another invalidated runtime condition cancels the remaining animation without breaking queued blocks.
|
||||
- [x] A player cannot start overlapping automatic fellings that could double-break or double-drop the same blocks.
|
||||
- [x] Only blocks actually removed by this felling are recorded for undo.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-004: Control personal tree felling"
|
||||
description: Let players persistently enable or disable their own automatic tree felling.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Control personal tree felling
|
||||
@@ -11,16 +11,16 @@ As a **player**, I want to turn automatic felling on or off independently of my
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/treefeller enabled <on|off>` enables or disables automatic felling for the issuing player.
|
||||
- [ ] `/treefeller enabled` without a value reports the player's current preference and whether an administrative lock currently overrides it.
|
||||
- [ ] The preference defaults to enabled for a player with no saved value.
|
||||
- [ ] Changing the preference does not alter species progress or earned unlocks.
|
||||
- [ ] Repeating the currently saved value is idempotent and reports that no change was needed.
|
||||
- [ ] The player receives clear confirmation after a successful change.
|
||||
- [ ] The saved preference survives logout and server restart.
|
||||
- [ ] `/treefeller` provides concise usage for `enabled`, `unlocked`, and `undo` without advertising inaccessible administrative commands.
|
||||
- [ ] Position-aware autocomplete suggests player subcommands and valid `on` or `off` values.
|
||||
- [ ] Player commands use player permissions that are distinct from `treefeller.admin`; possession of player permissions does not grant `/treefelleradmin` access.
|
||||
- [x] `/treefeller enabled <on|off>` enables or disables automatic felling for the issuing player.
|
||||
- [x] `/treefeller enabled` without a value reports the player's current preference and whether an administrative lock currently overrides it.
|
||||
- [x] The preference defaults to enabled for a player with no saved value.
|
||||
- [x] Changing the preference does not alter species progress or earned unlocks.
|
||||
- [x] Repeating the currently saved value is idempotent and reports that no change was needed.
|
||||
- [x] The player receives clear confirmation after a successful change.
|
||||
- [x] The saved preference survives logout and server restart.
|
||||
- [x] `/treefeller` provides concise usage for `enabled`, `unlocked`, and `undo` without advertising inaccessible administrative commands.
|
||||
- [x] Position-aware autocomplete suggests player subcommands and valid `on` or `off` values.
|
||||
- [x] Player commands use player permissions that are distinct from `treefeller.admin`; possession of player permissions does not grant `/treefelleradmin` access.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-005: Undo the last felled tree"
|
||||
description: Safely restore the player's most recent automatic felling without creating replacement materials.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Undo the last felled tree
|
||||
@@ -11,21 +11,21 @@ As a **player**, I want to undo my latest automatically felled tree so that I ca
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/treefeller undo` targets only the issuing player's most recent automatic felling.
|
||||
- [ ] An undo record contains only blocks successfully removed by the operation, including the initiating trunk block when its original state can be captured safely.
|
||||
- [ ] Each record retains the world, block coordinates, original block material, and original block data needed to restore orientation and other supported state.
|
||||
- [ ] Only one undo record is retained per player; a later felling replaces the earlier record after the later operation has removed at least one eligible tree block.
|
||||
- [ ] An undo remains available for six minutes after the felling by default, with a configurable duration.
|
||||
- [ ] Undo performs a complete preflight before changing the world or inventory.
|
||||
- [ ] Preflight requires every target world and chunk to be available and every target position to remain safely restorable; an occupied or otherwise unsafe position fails the whole undo.
|
||||
- [ ] Preflight calculates the exact replacement materials required to reconstruct all recorded trunk states and requires those aggregate materials in the issuing player's inventory.
|
||||
- [ ] If inventory is insufficient, no item or block changes occur and the error lists every missing material with its missing quantity.
|
||||
- [ ] On success, required materials are removed exactly once and all recorded blocks are restored as one logical operation.
|
||||
- [ ] If an unexpected restoration failure occurs after preflight, the implementation avoids a silent partial result and reports the recovery action needed to administrators.
|
||||
- [ ] A successful undo consumes the record so that it cannot be repeated.
|
||||
- [ ] An expired, absent, already-used, or invalid undo produces a clear message and makes no changes.
|
||||
- [ ] Undo restores trunk blocks only; it does not restore foliage, caps, roots, drops, experience, or axe durability.
|
||||
- [ ] Undo has a distinct player permission and never grants access to `/treefelleradmin`.
|
||||
- [x] `/treefeller undo` targets only the issuing player's most recent automatic felling.
|
||||
- [x] An undo record contains only blocks successfully removed by the operation, including the initiating trunk block when its original state can be captured safely.
|
||||
- [x] Each record retains the world, block coordinates, original block material, and original block data needed to restore orientation and other supported state.
|
||||
- [x] Only one undo record is retained per player; a later felling replaces the earlier record after the later operation has removed at least one eligible tree block.
|
||||
- [x] An undo remains available for six minutes after the felling by default, with a configurable duration.
|
||||
- [x] Undo performs a complete preflight before changing the world or inventory.
|
||||
- [x] Preflight requires every target world and chunk to be available and every target position to remain safely restorable; an occupied or otherwise unsafe position fails the whole undo.
|
||||
- [x] Preflight calculates the exact replacement materials required to reconstruct all recorded trunk states and requires those aggregate materials in the issuing player's inventory.
|
||||
- [x] If inventory is insufficient, no item or block changes occur and the error lists every missing material with its missing quantity.
|
||||
- [x] On success, required materials are removed exactly once and all recorded blocks are restored as one logical operation.
|
||||
- [x] If an unexpected restoration failure occurs after preflight, the implementation avoids a silent partial result and reports the recovery action needed to administrators.
|
||||
- [x] A successful undo consumes the record so that it cannot be repeated.
|
||||
- [x] An expired, absent, already-used, or invalid undo produces a clear message and makes no changes.
|
||||
- [x] Undo restores trunk blocks only; it does not restore foliage, caps, roots, drops, experience, or axe durability.
|
||||
- [x] Undo has a distinct player permission and never grants access to `/treefelleradmin`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-006: Announce tree unlocks"
|
||||
description: Celebrate each newly earned species and explain how to control or undo automatic felling.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-006: Announce tree unlocks
|
||||
@@ -11,14 +11,14 @@ As a **player**, I want visible and actionable feedback when I unlock a species
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Earning a species unlock displays a configurable on-screen title and subtitle naming the species.
|
||||
- [ ] Earning a species unlock also sends a configurable chat message that explains that sneaking prevents automatic felling and names `/treefeller undo`.
|
||||
- [ ] Title text, subtitle text, fade-in time, display time, fade-out time, and chat text are configurable.
|
||||
- [ ] Messages support the project's chosen Spigot formatting convention and a documented species placeholder.
|
||||
- [ ] The boss bar for the newly unlocked species is removed when its unlock announcement is shown.
|
||||
- [ ] An earned species produces its unlock announcement exactly once unless an administrator later resets that species and the player earns it again.
|
||||
- [ ] An administrative grant clearly informs an online target that access was granted but does not falsely present it as a mined-block achievement.
|
||||
- [ ] Invalid or cancelled breaks never generate an unlock announcement.
|
||||
- [x] Earning a species unlock displays a configurable on-screen title and subtitle naming the species.
|
||||
- [x] Earning a species unlock also sends a configurable chat message that explains that sneaking prevents automatic felling and names `/treefeller undo`.
|
||||
- [x] Title text, subtitle text, fade-in time, display time, fade-out time, and chat text are configurable.
|
||||
- [x] Messages support the project's chosen Spigot formatting convention and a documented species placeholder.
|
||||
- [x] The boss bar for the newly unlocked species is removed when its unlock announcement is shown.
|
||||
- [x] An earned species produces its unlock announcement exactly once unless an administrator later resets that species and the player earns it again.
|
||||
- [x] An administrative grant clearly informs an online target that access was granted but does not falsely present it as a mined-block achievement.
|
||||
- [x] Invalid or cancelled breaks never generate an unlock announcement.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-007: Administer Tree Feller"
|
||||
description: Give administrators separate, structured commands for player access, progress, and species thresholds.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-007: Administer Tree Feller
|
||||
@@ -11,22 +11,22 @@ As a **server administrator**, I want a dedicated administrative command tree so
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] All administrative operations are rooted at `/treefelleradmin` rather than `/treefeller`.
|
||||
- [ ] Administrative commands require `treefeller.admin`, which server operators receive by default and ordinary player permissions never imply.
|
||||
- [ ] `/treefelleradmin player <name|uuid> status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state.
|
||||
- [ ] `/treefelleradmin player <name|uuid> tree <type> grant` grants that species permanently without altering unrelated species.
|
||||
- [ ] `/treefelleradmin player <name|uuid> tree <type> reset` removes that species' unlock and resets its progress to zero without altering unrelated species.
|
||||
- [ ] A reset player can earn the species again and receive its normal earned-unlock announcement.
|
||||
- [ ] `/treefelleradmin player <name|uuid> locked <on|off>` controls an override that prevents all automatic felling for the player without changing their preference, progress, or unlocks.
|
||||
- [ ] An administratively locked player may continue accruing qualifying manual progress and receives a clear explanation when automatic felling is suppressed.
|
||||
- [ ] `/treefelleradmin threshold <type> <blocks>` validates and persistently changes the named species' unlock threshold.
|
||||
- [ ] Lowering a threshold does not scan or immediately mutate all player records; each affected player unlocks on their next qualifying block of that species.
|
||||
- [ ] Raising a threshold never revokes existing unlocks.
|
||||
- [ ] Player targets resolve exact online names, previously known names, and UUIDs without confusing players who have used the same name.
|
||||
- [ ] Tree-type arguments use stable documented identifiers covering every supported species.
|
||||
- [ ] Autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, tree types, actions, and values for the current argument position.
|
||||
- [ ] Every successful mutation reports exactly what changed to the administrator and, when online, the affected player.
|
||||
- [ ] Invalid or unauthorized requests make no partial state or configuration changes.
|
||||
- [x] All administrative operations are rooted at `/treefelleradmin` rather than `/treefeller`.
|
||||
- [x] Administrative commands require `treefeller.admin`, which server operators receive by default and ordinary player permissions never imply.
|
||||
- [x] `/treefelleradmin player <name|uuid> status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state.
|
||||
- [x] `/treefelleradmin player <name|uuid> tree <type> grant` grants that species permanently without altering unrelated species.
|
||||
- [x] `/treefelleradmin player <name|uuid> tree <type> reset` removes that species' unlock and resets its progress to zero without altering unrelated species.
|
||||
- [x] A reset player can earn the species again and receive its normal earned-unlock announcement.
|
||||
- [x] `/treefelleradmin player <name|uuid> locked <on|off>` controls an override that prevents all automatic felling for the player without changing their preference, progress, or unlocks.
|
||||
- [x] An administratively locked player may continue accruing qualifying manual progress and receives a clear explanation when automatic felling is suppressed.
|
||||
- [x] `/treefelleradmin threshold <type> <blocks>` validates and persistently changes the named species' unlock threshold.
|
||||
- [x] Lowering a threshold does not scan or immediately mutate all player records; each affected player unlocks on their next qualifying block of that species.
|
||||
- [x] Raising a threshold never revokes existing unlocks.
|
||||
- [x] Player targets resolve exact online names, previously known names, and UUIDs without confusing players who have used the same name.
|
||||
- [x] Tree-type arguments use stable documented identifiers covering every supported species.
|
||||
- [x] Autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, tree types, actions, and values for the current argument position.
|
||||
- [x] Every successful mutation reports exactly what changed to the administrator and, when online, the affected player.
|
||||
- [x] Invalid or unauthorized requests make no partial state or configuration changes.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-009: Build and release Tree Feller"
|
||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||
status: in-progress
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-009: Build and release Tree Feller
|
||||
@@ -14,16 +14,16 @@ As a **plugin maintainer**, I want automated builds and releases modeled on `spi
|
||||
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [ ] Pushes and pull requests build and test Tree Feller in Gitea Actions.
|
||||
- [ ] Pull requests validate conventional commit messages.
|
||||
- [ ] CI stores a development Tree Feller JAR as a workflow artifact.
|
||||
- [ ] Main-branch conventional commits drive semantic versioning.
|
||||
- [ ] A successful release builds a versioned Tree Feller JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] Pushes and pull requests build and test Tree Feller in Gitea Actions.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI stores a development Tree Feller JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits drive semantic versioning.
|
||||
- [x] A successful release builds a versioned Tree Feller JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] Build files, Gradle wrapper, workflows, release behavior, and Java dependency versions follow `../spigot-getgud/` where applicable while using Tree Feller names and identifiers.
|
||||
- [x] Plugin metadata declares the player and administrative command trees with separate permissions.
|
||||
- [x] The approved user-story bundle is committed before implementation begins.
|
||||
- [ ] Subsequent implementation follows test-driven development where practical and keeps story statuses and acceptance criteria synchronized with verified behavior.
|
||||
- [ ] No remote push is performed until the maintainer confirms that the Gitea repository and release token secret are ready.
|
||||
- [x] Subsequent implementation follows test-driven development where practical and keeps story statuses and acceptance criteria synchronized with verified behavior.
|
||||
- [x] No remote push is performed until the maintainer confirms that the Gitea repository and release token secret are ready.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-010: Run a local Docker test server"
|
||||
description: Provide a repeatable Docker Compose harness that builds and loads Tree Feller on an isolated Spigot 26.2 server.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-010: Run a local Docker test server
|
||||
|
||||
As a **plugin developer**, I want one command to run the current Tree Feller build on a local containerized server so that I can perform repeatable integration and gameplay testing.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] One repository script builds and verifies the plugin before starting an isolated Spigot 26.2 server through Docker Compose.
|
||||
- [x] The current Tree Feller JAR is mounted into the server automatically whenever the container is recreated.
|
||||
- [x] `WindMagi` is configured and verified at operator level 4 using the deterministic offline-mode UUID `6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e`.
|
||||
- [x] The Minecraft port binds only to `127.0.0.1:25565` by default.
|
||||
- [x] Startup waits for server health and verifies Tree Feller through RCON.
|
||||
- [x] Generated worlds, logs, configuration, and downloaded server artifacts are stored under `.docker/minecraft/` and excluded from Git.
|
||||
- [x] Stopping Compose preserves generated test state unless the operator explicitly deletes it.
|
||||
- [x] Existing unrelated containers are not modified.
|
||||
- [x] Maintainer documentation explains start, logs, stop, reset, timeout, and RCON-password controls.
|
||||
|
||||
## Scope
|
||||
|
||||
This harness is for local development only. It does not define production deployment, publish an image, expose Minecraft or RCON publicly, or persist its runtime state in Git.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||
- [US-005: Undo the last felled tree](us-005-undo-the-last-felled-tree.md)
|
||||
- [US-009: Build and release Tree Feller](us-009-build-and-release-tree-feller.md)
|
||||
- [User-story catalog](index.md)
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"uuid": "6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e",
|
||||
"name": "WindMagi",
|
||||
"level": 4,
|
||||
"bypassesPlayerLimit": false
|
||||
}
|
||||
]
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
compose_file="$project_root/compose.test.yml"
|
||||
wait_seconds="${TREE_FELLER_START_TIMEOUT:-900}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Required command not found: docker" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker is not running or is not accessible." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose v2 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$project_root"
|
||||
./gradlew clean check jar
|
||||
mkdir -p .docker/minecraft
|
||||
|
||||
# Recreate the container so the image installs the newly built read-only plugin JAR.
|
||||
docker compose -f "$compose_file" up -d --force-recreate
|
||||
container_id="$(docker compose -f "$compose_file" ps -q minecraft)"
|
||||
if [[ -z "$container_id" ]]; then
|
||||
echo "The Tree Feller test container was not created." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting up to ${wait_seconds}s for Spigot 26.2..."
|
||||
deadline=$((SECONDS + wait_seconds))
|
||||
while (( SECONDS < deadline )); do
|
||||
container_state="$(docker inspect --format '{{.State.Status}}' "$container_id")"
|
||||
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
||||
if [[ "$health_state" == "healthy" ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "$container_state" == "exited" || "$container_state" == "dead" ]]; then
|
||||
docker compose -f "$compose_file" logs --tail=150 minecraft >&2
|
||||
echo "The Minecraft server exited during startup." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
||||
if [[ "$health_state" != "healthy" ]]; then
|
||||
docker compose -f "$compose_file" logs --tail=150 minecraft >&2
|
||||
echo "Timed out waiting for Spigot 26.2 (status: $health_state)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugins="$(docker compose -f "$compose_file" exec -T minecraft rcon-cli plugins)"
|
||||
if [[ "$plugins" != *"TreeFeller"* ]]; then
|
||||
echo "$plugins" >&2
|
||||
echo "TreeFeller was not reported by the running server." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Offline mode assigns a deterministic UUID that differs from the account's online UUID.
|
||||
# Verify the exact identity instead of issuing `op WindMagi`, which can resolve online identity.
|
||||
offline_uuid="6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e"
|
||||
ops_json="$(docker compose -f "$compose_file" exec -T minecraft sh -lc 'cat /data/ops.json')"
|
||||
if [[ "$ops_json" != *'"name": "WindMagi"'* || "$ops_json" != *"\"uuid\": \"${offline_uuid}\""* ]]; then
|
||||
echo "$ops_json" >&2
|
||||
echo "WindMagi's offline-mode UUID was not installed as an operator." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat <<'MESSAGE'
|
||||
Tree Feller test server is ready at localhost:25565.
|
||||
Operator: WindMagi
|
||||
View logs: docker compose -f compose.test.yml logs -f minecraft
|
||||
Stop: docker compose -f compose.test.yml down
|
||||
Reset: docker compose -f compose.test.yml down && rm -rf .docker/minecraft
|
||||
MESSAGE
|
||||
@@ -0,0 +1,207 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
|
||||
/** Runs bounded detected trunk blocks through Spigot's player break path over time. */
|
||||
public final class AnimatedTreeFellingEngine implements TreeFellingStarter, Listener, AutoCloseable {
|
||||
private static final BlockPoint ORIGIN = new BlockPoint(0, 0, 0);
|
||||
|
||||
private final DelayedTaskScheduler scheduler;
|
||||
private final AutomaticBreakRegistry automaticBreaks;
|
||||
private final Predicate<Player> runtimeEligibility;
|
||||
private final FellingObserver observer;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final Map<UUID, Session> activePlayers = new HashMap<>();
|
||||
private final Set<WorldBlockKey> claimedBlocks = new HashSet<>();
|
||||
|
||||
public AnimatedTreeFellingEngine(
|
||||
DelayedTaskScheduler scheduler,
|
||||
AutomaticBreakRegistry automaticBreaks,
|
||||
Predicate<Player> runtimeEligibility,
|
||||
FellingObserver observer,
|
||||
Consumer<Exception> failureHandler) {
|
||||
this.scheduler = scheduler;
|
||||
this.automaticBreaks = automaticBreaks;
|
||||
this.runtimeEligibility = runtimeEligibility;
|
||||
this.observer = observer;
|
||||
this.failureHandler = failureHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean start(
|
||||
Player player,
|
||||
Block initiatingBlock,
|
||||
TreeStructure tree,
|
||||
int delayTicks) {
|
||||
UUID playerId = player.getUniqueId();
|
||||
if (delayTicks < 1 || activePlayers.containsKey(playerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BlockWork> work = tree.trunkBlocks().stream()
|
||||
.filter(point -> !point.equals(ORIGIN))
|
||||
.sorted(Comparator.comparingInt(BlockPoint::y)
|
||||
.thenComparingInt(BlockPoint::x)
|
||||
.thenComparingInt(BlockPoint::z))
|
||||
.map(point -> {
|
||||
Block block = initiatingBlock.getRelative(point.x(), point.y(), point.z());
|
||||
return new BlockWork(block, block.getType());
|
||||
})
|
||||
.toList();
|
||||
Set<WorldBlockKey> claims = new HashSet<>();
|
||||
claims.add(WorldBlockKey.from(initiatingBlock));
|
||||
work.stream().map(BlockWork::block).map(WorldBlockKey::from).forEach(claims::add);
|
||||
if (claims.stream().anyMatch(claimedBlocks::contains)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Session session = new Session(
|
||||
player,
|
||||
initiatingBlock.getWorld(),
|
||||
new ArrayDeque<>(work),
|
||||
new ArrayList<>(List.of(FelledBlockSnapshot.capture(initiatingBlock))),
|
||||
claims,
|
||||
delayTicks);
|
||||
claimedBlocks.addAll(claims);
|
||||
activePlayers.put(playerId, session);
|
||||
if (work.isEmpty()) {
|
||||
complete(session);
|
||||
} else {
|
||||
scheduleNext(session);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void cancel(UUID playerId) {
|
||||
Session session = activePlayers.get(playerId);
|
||||
if (session != null) {
|
||||
complete(session);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
cancel(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onWorldUnload(WorldUnloadEvent event) {
|
||||
UUID worldId = event.getWorld().getUID();
|
||||
for (Session session : List.copyOf(activePlayers.values())) {
|
||||
if (session.world.getUID().equals(worldId)) {
|
||||
complete(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (UUID playerId : List.copyOf(activePlayers.keySet())) {
|
||||
cancel(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleNext(Session session) {
|
||||
session.scheduled = scheduler.schedule(() -> processNext(session), session.delayTicks);
|
||||
}
|
||||
|
||||
private void processNext(Session session) {
|
||||
if (activePlayers.get(session.player.getUniqueId()) != session || !canContinue(session)) {
|
||||
complete(session);
|
||||
return;
|
||||
}
|
||||
BlockWork next = session.pending.remove();
|
||||
Block block = next.block();
|
||||
if (block.getType() != next.expectedMaterial()
|
||||
|| !session.world.isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) {
|
||||
complete(session);
|
||||
return;
|
||||
}
|
||||
|
||||
FelledBlockSnapshot snapshot = FelledBlockSnapshot.capture(block);
|
||||
boolean broken;
|
||||
automaticBreaks.mark(block);
|
||||
try {
|
||||
broken = session.player.breakBlock(block);
|
||||
} catch (RuntimeException exception) {
|
||||
failureHandler.accept(exception);
|
||||
broken = false;
|
||||
} finally {
|
||||
automaticBreaks.unmark(block);
|
||||
}
|
||||
if (!broken) {
|
||||
complete(session);
|
||||
return;
|
||||
}
|
||||
session.removed.add(snapshot);
|
||||
if (session.pending.isEmpty()) {
|
||||
complete(session);
|
||||
} else {
|
||||
scheduleNext(session);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canContinue(Session session) {
|
||||
return session.player.isOnline()
|
||||
&& session.player.getWorld().equals(session.world)
|
||||
&& runtimeEligibility.test(session.player)
|
||||
&& TreeTools.isAxe(session.player.getInventory().getItemInMainHand().getType());
|
||||
}
|
||||
|
||||
private void complete(Session session) {
|
||||
if (activePlayers.remove(session.player.getUniqueId(), session)) {
|
||||
if (session.scheduled != null) {
|
||||
session.scheduled.cancel();
|
||||
}
|
||||
claimedBlocks.removeAll(session.claims);
|
||||
observer.onFelling(session.player, List.copyOf(session.removed));
|
||||
}
|
||||
}
|
||||
|
||||
private record BlockWork(Block block, Material expectedMaterial) {
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
private final Player player;
|
||||
private final World world;
|
||||
private final Queue<BlockWork> pending;
|
||||
private final List<FelledBlockSnapshot> removed;
|
||||
private final Set<WorldBlockKey> claims;
|
||||
private final int delayTicks;
|
||||
private ScheduledHandle scheduled;
|
||||
|
||||
private Session(
|
||||
Player player,
|
||||
World world,
|
||||
Queue<BlockWork> pending,
|
||||
List<FelledBlockSnapshot> removed,
|
||||
Set<WorldBlockKey> claims,
|
||||
int delayTicks) {
|
||||
this.player = player;
|
||||
this.world = world;
|
||||
this.pending = pending;
|
||||
this.removed = removed;
|
||||
this.claims = claims;
|
||||
this.delayTicks = delayTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
/** Marks blocks whose break was initiated by Tree Feller rather than a player chop. */
|
||||
public final class AutomaticBreakRegistry {
|
||||
private final Set<BlockKey> marked = new HashSet<>();
|
||||
|
||||
public void mark(Block block) {
|
||||
marked.add(BlockKey.from(block));
|
||||
}
|
||||
|
||||
public void unmark(Block block) {
|
||||
marked.remove(BlockKey.from(block));
|
||||
}
|
||||
|
||||
public boolean isMarked(Block block) {
|
||||
return marked.contains(BlockKey.from(block));
|
||||
}
|
||||
|
||||
private record BlockKey(UUID worldId, int x, int y, int z) {
|
||||
private static BlockKey from(Block block) {
|
||||
return new BlockKey(
|
||||
block.getWorld().getUID(), block.getX(), block.getY(), block.getZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Read-only blocks used by bounded tree detection. */
|
||||
@FunctionalInterface
|
||||
public interface BlockAccess {
|
||||
Material materialAt(BlockPoint point);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Integer location relative to the initiating tree block. */
|
||||
public record BlockPoint(int x, int y, int z) {
|
||||
public BlockPoint add(int deltaX, int deltaY, int deltaZ) {
|
||||
return new BlockPoint(x + deltaX, y + deltaY, z + deltaZ);
|
||||
}
|
||||
|
||||
public int chebyshevDistance(BlockPoint other) {
|
||||
return Math.max(
|
||||
Math.max(Math.abs(x - other.x), Math.abs(y - other.y)),
|
||||
Math.abs(z - other.z));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.boss.BossBar;
|
||||
|
||||
/** Creates a boss bar using current presentation settings. */
|
||||
@FunctionalInterface
|
||||
public interface BossBarFactory {
|
||||
BossBar create(TreeFellerSettings settings);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
/** Adapts world blocks to the testable relative tree scanner. */
|
||||
public final class BukkitTreeDetector implements TreeDetector {
|
||||
private static final BlockPoint ORIGIN = new BlockPoint(0, 0, 0);
|
||||
private final TreeStructureScanner scanner;
|
||||
|
||||
public BukkitTreeDetector(TreeStructureScanner scanner) {
|
||||
this.scanner = scanner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TreeStructure> detect(Block start) {
|
||||
BlockAccess access = point -> start.getRelative(point.x(), point.y(), point.z()).getType();
|
||||
return scanner.scan(access, ORIGIN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Delivers one persisted progress update to each presentation observer in order. */
|
||||
public final class CompositeProgressObserver implements ProgressObserver {
|
||||
private final List<ProgressObserver> observers;
|
||||
|
||||
public CompositeProgressObserver(ProgressObserver... observers) {
|
||||
this.observers = List.of(observers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(Player player, ProgressUpdate update) {
|
||||
observers.forEach(observer -> observer.onProgress(player, update));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Schedules a task after a number of server ticks. */
|
||||
@FunctionalInterface
|
||||
public interface DelayedTaskScheduler {
|
||||
ScheduledHandle schedule(Runnable task, long delayTicks);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
/** Original state needed to account for and later restore a felled trunk block. */
|
||||
public record FelledBlockSnapshot(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
Material material,
|
||||
String blockData) {
|
||||
|
||||
public static FelledBlockSnapshot capture(Block block) {
|
||||
return new FelledBlockSnapshot(
|
||||
block.getWorld().getUID(),
|
||||
block.getWorld().getName(),
|
||||
block.getX(),
|
||||
block.getY(),
|
||||
block.getZ(),
|
||||
block.getType(),
|
||||
block.getBlockData().getAsString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Receives the exact blocks removed when a felling completes or stops. */
|
||||
@FunctionalInterface
|
||||
public interface FellingObserver {
|
||||
void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Runtime-only record of one player's latest felling. */
|
||||
public record FellingRecord(
|
||||
UUID playerId, Instant felledAt, List<FelledBlockSnapshot> blocks) {
|
||||
public FellingRecord {
|
||||
blocks = List.copyOf(blocks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** In-memory, one-record-per-player felling history for undo. */
|
||||
public final class LastFellingStore implements FellingObserver {
|
||||
private final Clock clock;
|
||||
private final Map<UUID, FellingRecord> records = new HashMap<>();
|
||||
|
||||
public LastFellingStore(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks) {
|
||||
if (!removedBlocks.isEmpty()) {
|
||||
records.put(
|
||||
player.getUniqueId(),
|
||||
new FellingRecord(player.getUniqueId(), Instant.now(clock), removedBlocks));
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<FellingRecord> get(UUID playerId) {
|
||||
return Optional.ofNullable(records.get(playerId));
|
||||
}
|
||||
|
||||
public void remove(UUID playerId) {
|
||||
records.remove(playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Player state store that also supports administrative discovery. */
|
||||
public interface PlayerStateCatalog extends PlayerStateStore {
|
||||
List<PlayerTreeFellerState> loadAll();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable player-state boundary used by gameplay services. */
|
||||
public interface PlayerStateStore {
|
||||
Optional<PlayerTreeFellerState> load(UUID playerId);
|
||||
|
||||
void save(PlayerTreeFellerState state) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Resolves exact live names, durable aliases, and authoritative UUIDs safely. */
|
||||
public final class PlayerTargetResolver {
|
||||
private final Server server;
|
||||
private final PlayerStateCatalog states;
|
||||
|
||||
public PlayerTargetResolver(Server server, PlayerStateCatalog states) {
|
||||
this.server = server;
|
||||
this.states = states;
|
||||
}
|
||||
|
||||
public TargetResolution resolve(String value) {
|
||||
Optional<UUID> parsedId = parseUuid(value);
|
||||
if (parsedId.isPresent()) {
|
||||
UUID playerId = parsedId.orElseThrow();
|
||||
Player online = server.getPlayer(playerId);
|
||||
Optional<PlayerTreeFellerState> saved = states.load(playerId);
|
||||
if (saved.isEmpty() && online == null) {
|
||||
return TargetResolution.notFound();
|
||||
}
|
||||
PlayerTreeFellerState state = saved.orElseGet(() ->
|
||||
PlayerTreeFellerState.initial(playerId, online.getName()));
|
||||
return TargetResolution.found(toTarget(state, online));
|
||||
}
|
||||
|
||||
for (Player online : server.getOnlinePlayers()) {
|
||||
if (online.getName().equalsIgnoreCase(value)) {
|
||||
PlayerTreeFellerState state = states.load(online.getUniqueId())
|
||||
.orElseGet(() -> PlayerTreeFellerState.initial(
|
||||
online.getUniqueId(), online.getName()))
|
||||
.observeName(online.getName());
|
||||
return TargetResolution.found(toTarget(state, online));
|
||||
}
|
||||
}
|
||||
|
||||
String normalized = value.toLowerCase(Locale.ROOT);
|
||||
List<PlayerTreeFellerState> matches = states.loadAll().stream()
|
||||
.filter(state -> state.knownNames().stream()
|
||||
.anyMatch(name -> name.toLowerCase(Locale.ROOT).equals(normalized)))
|
||||
.toList();
|
||||
if (matches.isEmpty()) {
|
||||
return TargetResolution.notFound();
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
return TargetResolution.ambiguous();
|
||||
}
|
||||
PlayerTreeFellerState state = matches.get(0);
|
||||
return TargetResolution.found(toTarget(state, server.getPlayer(state.playerId())));
|
||||
}
|
||||
|
||||
public List<String> suggestions() {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
server.getOnlinePlayers().stream()
|
||||
.map(Player::getName)
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.forEach(names::add);
|
||||
states.loadAll().stream()
|
||||
.map(PlayerTreeFellerState::latestName)
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.forEach(names::add);
|
||||
return List.copyOf(new ArrayList<>(names));
|
||||
}
|
||||
|
||||
private ResolvedPlayer toTarget(PlayerTreeFellerState state, Player online) {
|
||||
String name = online == null ? state.latestName() : online.getName();
|
||||
return new ResolvedPlayer(state.playerId(), name, state, online);
|
||||
}
|
||||
|
||||
private Optional<UUID> parseUuid(String value) {
|
||||
try {
|
||||
return Optional.of(UUID.fromString(value));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.boss.BossBar;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Maintains one resetting, temporary species-progress boss bar per player. */
|
||||
public final class ProgressBossBarObserver implements ProgressObserver, AutoCloseable {
|
||||
private final Supplier<TreeFellerSettings> settings;
|
||||
private final BossBarFactory bars;
|
||||
private final DelayedTaskScheduler scheduler;
|
||||
private final Map<UUID, Session> sessions = new HashMap<>();
|
||||
|
||||
public ProgressBossBarObserver(
|
||||
Supplier<TreeFellerSettings> settings,
|
||||
BossBarFactory bars,
|
||||
DelayedTaskScheduler scheduler) {
|
||||
this.settings = settings;
|
||||
this.bars = bars;
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(Player player, ProgressUpdate update) {
|
||||
if (update.state().isUnlocked(update.species())) {
|
||||
hide(player.getUniqueId());
|
||||
return;
|
||||
}
|
||||
TreeFellerSettings current = settings.get();
|
||||
Session session = sessions.computeIfAbsent(
|
||||
player.getUniqueId(), ignored -> new Session(bars.create(current)));
|
||||
if (session.timeout != null) {
|
||||
session.timeout.cancel();
|
||||
}
|
||||
BossBar bar = session.bar;
|
||||
bar.setColor(current.bossBarColor());
|
||||
bar.setStyle(current.bossBarStyle());
|
||||
bar.setTitle(format(current.bossBarText(), update));
|
||||
double progress = Math.min(1.0D, (double) update.progress() / update.threshold());
|
||||
bar.setProgress(progress);
|
||||
bar.addPlayer(player);
|
||||
session.player = player;
|
||||
bar.setVisible(true);
|
||||
session.timeout = scheduler.schedule(
|
||||
() -> hideIfCurrent(player.getUniqueId(), session),
|
||||
current.bossBarTimeoutSeconds() * 20L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (UUID playerId : ListCopy.keys(sessions)) {
|
||||
hide(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void hideIfCurrent(UUID playerId, Session expected) {
|
||||
if (sessions.get(playerId) == expected) {
|
||||
hide(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void hide(UUID playerId) {
|
||||
Session removed = sessions.remove(playerId);
|
||||
if (removed == null) {
|
||||
return;
|
||||
}
|
||||
if (removed.timeout != null) {
|
||||
removed.timeout.cancel();
|
||||
}
|
||||
if (removed.player != null) {
|
||||
removed.bar.removePlayer(removed.player);
|
||||
}
|
||||
removed.bar.setVisible(false);
|
||||
}
|
||||
|
||||
private String format(String template, ProgressUpdate update) {
|
||||
return template
|
||||
.replace("{species}", update.species().displayName())
|
||||
.replace("{progress}", Long.toString(update.progress()))
|
||||
.replace("{threshold}", Integer.toString(update.threshold()))
|
||||
.replace('&', '\u00a7');
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
private final BossBar bar;
|
||||
private Player player;
|
||||
private ScheduledHandle timeout;
|
||||
|
||||
private Session(BossBar bar) {
|
||||
this.bar = bar;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ListCopy {
|
||||
private ListCopy() {
|
||||
}
|
||||
|
||||
private static java.util.List<UUID> keys(Map<UUID, Session> source) {
|
||||
return java.util.List.copyOf(source.keySet());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Receives a successfully persisted qualifying progress update. */
|
||||
@FunctionalInterface
|
||||
public interface ProgressObserver {
|
||||
void onProgress(Player player, ProgressUpdate update);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Result of one qualifying manually mined tree block. */
|
||||
public record ProgressUpdate(
|
||||
PlayerTreeFellerState state,
|
||||
TreeSpecies species,
|
||||
long progress,
|
||||
int threshold,
|
||||
boolean newlyUnlocked) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Unambiguous administrative player identity and optional live connection. */
|
||||
public record ResolvedPlayer(
|
||||
UUID playerId,
|
||||
String displayName,
|
||||
PlayerTreeFellerState state,
|
||||
Player onlinePlayer) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Cancellation boundary for a delayed UI task. */
|
||||
@FunctionalInterface
|
||||
public interface ScheduledHandle {
|
||||
void cancel();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Result of permission-independent administrative identity lookup. */
|
||||
public record TargetResolution(TargetResolutionStatus status, ResolvedPlayer target) {
|
||||
public static TargetResolution found(ResolvedPlayer target) {
|
||||
return new TargetResolution(TargetResolutionStatus.FOUND, target);
|
||||
}
|
||||
|
||||
public static TargetResolution notFound() {
|
||||
return new TargetResolution(TargetResolutionStatus.NOT_FOUND, null);
|
||||
}
|
||||
|
||||
public static TargetResolution ambiguous() {
|
||||
return new TargetResolution(TargetResolutionStatus.AMBIGUOUS, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Player target lookup result. */
|
||||
public enum TargetResolutionStatus {
|
||||
FOUND,
|
||||
NOT_FOUND,
|
||||
AMBIGUOUS
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
/** Validates a Bukkit block as the start of a supported tree. */
|
||||
@FunctionalInterface
|
||||
public interface TreeDetector {
|
||||
Optional<TreeStructure> detect(Block start);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Permission-aware `/treefelleradmin` command tree. */
|
||||
public final class TreeFellerAdminCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> ROOTS = List.of("player", "threshold");
|
||||
private static final List<String> PLAYER_PROPERTIES = List.of("locked", "status", "tree");
|
||||
private static final List<String> BOOLEAN_VALUES = List.of("off", "on");
|
||||
private static final List<String> TREE_ACTIONS = List.of("grant", "reset");
|
||||
|
||||
private final PlayerStateCatalog states;
|
||||
private final TreeFellerSettingsService settings;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final PlayerTargetResolver targets;
|
||||
|
||||
public TreeFellerAdminCommand(
|
||||
Server server,
|
||||
PlayerStateCatalog states,
|
||||
TreeFellerSettingsService settings,
|
||||
Consumer<Exception> failureHandler) {
|
||||
this.states = states;
|
||||
this.settings = settings;
|
||||
this.failureHandler = failureHandler;
|
||||
this.targets = new PlayerTargetResolver(server, states);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!sender.hasPermission("treefeller.admin")) {
|
||||
sender.sendMessage("You do not have permission to administer Tree Feller.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0) {
|
||||
sendUsage(sender);
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equalsIgnoreCase("threshold")) {
|
||||
changeThreshold(sender, arguments);
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equalsIgnoreCase("player")) {
|
||||
administerPlayer(sender, arguments);
|
||||
return true;
|
||||
}
|
||||
sendUsage(sender);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments) {
|
||||
if (!sender.hasPermission("treefeller.admin")) {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return matching(ROOTS, arguments[0]);
|
||||
}
|
||||
if (arguments[0].equalsIgnoreCase("threshold")) {
|
||||
if (arguments.length == 2) {
|
||||
return matching(treeIdentifiers(), arguments[1]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
if (!arguments[0].equalsIgnoreCase("player")) {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 2) {
|
||||
return matching(targets.suggestions(), arguments[1]);
|
||||
}
|
||||
if (arguments.length == 3) {
|
||||
return matching(PLAYER_PROPERTIES, arguments[2]);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[2].equalsIgnoreCase("locked")) {
|
||||
return matching(BOOLEAN_VALUES, arguments[3]);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[2].equalsIgnoreCase("tree")) {
|
||||
return matching(treeIdentifiers(), arguments[3]);
|
||||
}
|
||||
if (arguments.length == 5 && arguments[2].equalsIgnoreCase("tree")) {
|
||||
return matching(TREE_ACTIONS, arguments[4]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private void changeThreshold(CommandSender sender, String[] arguments) {
|
||||
if (arguments.length != 3) {
|
||||
sender.sendMessage("Usage: /treefelleradmin threshold <type> <blocks>");
|
||||
return;
|
||||
}
|
||||
Optional<TreeSpecies> species = TreeSpecies.fromId(arguments[1]);
|
||||
if (species.isEmpty()) {
|
||||
sender.sendMessage("Unknown tree type: " + arguments[1]);
|
||||
return;
|
||||
}
|
||||
int threshold;
|
||||
try {
|
||||
threshold = Integer.parseInt(arguments[2]);
|
||||
} catch (NumberFormatException exception) {
|
||||
sender.sendMessage("Threshold must be a whole number.");
|
||||
return;
|
||||
}
|
||||
TreeSpecies tree = species.orElseThrow();
|
||||
int current = settings.current().threshold(tree);
|
||||
if (current == threshold) {
|
||||
sender.sendMessage(tree.displayName() + " already requires " + threshold + " blocks.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
settings.changeThreshold(tree, threshold);
|
||||
sender.sendMessage(tree.displayName() + " threshold changed from "
|
||||
+ current + " to " + threshold + " blocks.");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
failureHandler.accept(exception);
|
||||
sender.sendMessage("The threshold could not be saved; no change was applied.");
|
||||
}
|
||||
}
|
||||
|
||||
private void administerPlayer(CommandSender sender, String[] arguments) {
|
||||
if (arguments.length < 3) {
|
||||
sendUsage(sender);
|
||||
return;
|
||||
}
|
||||
TargetResolution resolution = targets.resolve(arguments[1]);
|
||||
if (resolution.status() == TargetResolutionStatus.NOT_FOUND) {
|
||||
sender.sendMessage("Player not found: " + arguments[1]);
|
||||
return;
|
||||
}
|
||||
if (resolution.status() == TargetResolutionStatus.AMBIGUOUS) {
|
||||
sender.sendMessage("Player name is ambiguous; use the player's UUID.");
|
||||
return;
|
||||
}
|
||||
ResolvedPlayer target = resolution.target();
|
||||
switch (arguments[2].toLowerCase(Locale.ROOT)) {
|
||||
case "status" -> showStatus(sender, target, arguments);
|
||||
case "locked" -> changeLock(sender, target, arguments);
|
||||
case "tree" -> changeTree(sender, target, arguments);
|
||||
default -> sendUsage(sender);
|
||||
}
|
||||
}
|
||||
|
||||
private void showStatus(CommandSender sender, ResolvedPlayer target, String[] arguments) {
|
||||
if (arguments.length != 3) {
|
||||
sender.sendMessage("Usage: /treefelleradmin player <name|uuid> status");
|
||||
return;
|
||||
}
|
||||
PlayerTreeFellerState state = target.state();
|
||||
sender.sendMessage("Tree Feller status for " + target.displayName()
|
||||
+ " (" + target.playerId() + "):");
|
||||
sender.sendMessage("- preference: " + (state.enabled() ? "enabled" : "disabled"));
|
||||
sender.sendMessage("- administrative lock: " + (state.locked() ? "on" : "off"));
|
||||
for (TreeSpecies species : TreeSpecies.values()) {
|
||||
sender.sendMessage("- " + species.displayName() + ": "
|
||||
+ (state.isUnlocked(species) ? "unlocked" : "locked")
|
||||
+ " (" + state.progress(species) + "/"
|
||||
+ settings.current().threshold(species) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void changeLock(CommandSender sender, ResolvedPlayer target, String[] arguments) {
|
||||
if (arguments.length != 4) {
|
||||
sender.sendMessage("Usage: /treefelleradmin player <name|uuid> locked <on|off>");
|
||||
return;
|
||||
}
|
||||
Optional<Boolean> requested = parseBoolean(arguments[3]);
|
||||
if (requested.isEmpty()) {
|
||||
sender.sendMessage("Lock value must be on or off.");
|
||||
return;
|
||||
}
|
||||
boolean locked = requested.orElseThrow();
|
||||
PlayerTreeFellerState state = target.state();
|
||||
if (state.locked() == locked) {
|
||||
sender.sendMessage(target.displayName() + " is already "
|
||||
+ (locked ? "locked" : "unlocked") + ".");
|
||||
return;
|
||||
}
|
||||
PlayerTreeFellerState changed = state.withLocked(locked);
|
||||
if (save(sender, changed)) {
|
||||
sender.sendMessage("Automatic felling for " + target.displayName() + " is now "
|
||||
+ (locked ? "locked" : "unlocked") + ".");
|
||||
notifyTarget(target, "An administrator "
|
||||
+ (locked ? "locked" : "unlocked") + " automatic tree felling for you.");
|
||||
}
|
||||
}
|
||||
|
||||
private void changeTree(CommandSender sender, ResolvedPlayer target, String[] arguments) {
|
||||
if (arguments.length != 5) {
|
||||
sender.sendMessage(
|
||||
"Usage: /treefelleradmin player <name|uuid> tree <type> <grant|reset>");
|
||||
return;
|
||||
}
|
||||
Optional<TreeSpecies> parsedSpecies = TreeSpecies.fromId(arguments[3]);
|
||||
if (parsedSpecies.isEmpty()) {
|
||||
sender.sendMessage("Unknown tree type: " + arguments[3]);
|
||||
return;
|
||||
}
|
||||
TreeSpecies species = parsedSpecies.orElseThrow();
|
||||
PlayerTreeFellerState state = target.state();
|
||||
if (arguments[4].equalsIgnoreCase("grant")) {
|
||||
if (state.isUnlocked(species)) {
|
||||
sender.sendMessage(target.displayName() + " already has "
|
||||
+ species.displayName() + " unlocked.");
|
||||
return;
|
||||
}
|
||||
if (save(sender, state.withUnlocked(species, true))) {
|
||||
sender.sendMessage("Granted " + species.displayName() + " felling to "
|
||||
+ target.displayName() + ".");
|
||||
notifyTarget(target, "An administrator granted "
|
||||
+ species.displayName() + " Tree Feller access.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (arguments[4].equalsIgnoreCase("reset")) {
|
||||
if (!state.isUnlocked(species) && state.progress(species) == 0L) {
|
||||
sender.sendMessage(target.displayName() + " already has no "
|
||||
+ species.displayName() + " progress.");
|
||||
return;
|
||||
}
|
||||
PlayerTreeFellerState changed = state
|
||||
.withUnlocked(species, false)
|
||||
.withProgress(species, 0L);
|
||||
if (save(sender, changed)) {
|
||||
sender.sendMessage("Reset " + species.displayName() + " progress for "
|
||||
+ target.displayName() + ".");
|
||||
notifyTarget(target, "An administrator reset your "
|
||||
+ species.displayName() + " Tree Feller progress.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
sender.sendMessage("Tree action must be grant or reset.");
|
||||
}
|
||||
|
||||
private boolean save(CommandSender sender, PlayerTreeFellerState state) {
|
||||
try {
|
||||
states.save(state);
|
||||
return true;
|
||||
} catch (IOException exception) {
|
||||
failureHandler.accept(exception);
|
||||
sender.sendMessage("Player state could not be saved; no change was applied.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyTarget(ResolvedPlayer target, String message) {
|
||||
Player online = target.onlinePlayer();
|
||||
if (online != null) {
|
||||
online.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /treefelleradmin <player|threshold>");
|
||||
}
|
||||
|
||||
private Optional<Boolean> parseBoolean(String value) {
|
||||
if (value.equalsIgnoreCase("on")) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (value.equalsIgnoreCase("off")) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private List<String> treeIdentifiers() {
|
||||
return Arrays.stream(TreeSpecies.values()).map(TreeSpecies::id).toList();
|
||||
}
|
||||
|
||||
private List<String> matching(List<String> values, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream()
|
||||
.filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.ToIntFunction;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Player-only `/treefeller` command tree. */
|
||||
public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> SUBCOMMANDS = List.of("enabled", "undo", "unlocked");
|
||||
private static final List<String> BOOLEAN_VALUES = List.of("off", "on");
|
||||
|
||||
private final PlayerStateStore states;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final Function<String, String> messages;
|
||||
private final ToIntFunction<TreeSpecies> thresholds;
|
||||
private final UndoAction undoAction;
|
||||
|
||||
public TreeFellerCommand(PlayerStateStore states, Consumer<Exception> failureHandler) {
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
TreeFellerCommand::defaultMessage,
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages) {
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
messages,
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages,
|
||||
ToIntFunction<TreeSpecies> thresholds) {
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
messages,
|
||||
thresholds,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages,
|
||||
ToIntFunction<TreeSpecies> thresholds,
|
||||
UndoAction undoAction) {
|
||||
this.states = states;
|
||||
this.failureHandler = failureHandler;
|
||||
this.messages = messages;
|
||||
this.thresholds = thresholds;
|
||||
this.undoAction = undoAction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Tree Feller player commands must be used in game.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equalsIgnoreCase("undo")) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage("Usage: /treefeller undo");
|
||||
} else if (!player.hasPermission("treefeller.undo")) {
|
||||
player.sendMessage("You do not have permission to undo felled trees.");
|
||||
} else {
|
||||
showUndoResult(player, undoAction.undo(player));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
PlayerTreeFellerState state = stateFor(player);
|
||||
if (arguments[0].equalsIgnoreCase("unlocked")) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage("Usage: /treefeller unlocked");
|
||||
return true;
|
||||
}
|
||||
showUnlocks(player, state);
|
||||
return true;
|
||||
}
|
||||
if (!arguments[0].equalsIgnoreCase("enabled")) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arguments.length == 1) {
|
||||
player.sendMessage("Tree Feller is " + (state.enabled() ? "enabled" : "disabled") + ".");
|
||||
if (state.locked()) {
|
||||
player.sendMessage("An administrative lock currently overrides your preference.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 2) {
|
||||
player.sendMessage("Usage: /treefeller enabled <on|off>");
|
||||
return true;
|
||||
}
|
||||
|
||||
Optional<Boolean> requested = parseBoolean(arguments[1]);
|
||||
if (requested.isEmpty()) {
|
||||
player.sendMessage("Usage: /treefeller enabled <on|off>");
|
||||
return true;
|
||||
}
|
||||
boolean enabled = requested.orElseThrow();
|
||||
if (state.enabled() == enabled) {
|
||||
player.sendMessage("Tree Feller is already " + (enabled ? "enabled" : "disabled") + ".");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
states.save(state.withEnabled(enabled));
|
||||
player.sendMessage(color(messages.apply(enabled ? "enabled" : "disabled")));
|
||||
} catch (IOException exception) {
|
||||
failureHandler.accept(exception);
|
||||
player.sendMessage("Tree Feller could not save your preference; no change was applied.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments) {
|
||||
if (arguments.length == 1) {
|
||||
return matching(SUBCOMMANDS, arguments[0]);
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("enabled")) {
|
||||
return matching(BOOLEAN_VALUES, arguments[1]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private PlayerTreeFellerState stateFor(Player player) {
|
||||
return states.load(player.getUniqueId())
|
||||
.orElseGet(() -> PlayerTreeFellerState.initial(
|
||||
player.getUniqueId(), player.getName()))
|
||||
.observeName(player.getName());
|
||||
}
|
||||
|
||||
private void showUndoResult(Player player, UndoResult result) {
|
||||
switch (result.status()) {
|
||||
case SUCCESS -> player.sendMessage("Tree restored successfully.");
|
||||
case NONE -> player.sendMessage(color(messages.apply("no-undo")));
|
||||
case EXPIRED -> player.sendMessage(color(messages.apply("undo-expired")));
|
||||
case MISSING_MATERIALS -> {
|
||||
player.sendMessage("Undo requires additional materials:");
|
||||
result.missingMaterials().entrySet().stream()
|
||||
.sorted(java.util.Map.Entry.comparingByKey())
|
||||
.forEach(entry -> player.sendMessage("- "
|
||||
+ displayMaterial(entry.getKey()) + " x" + entry.getValue()));
|
||||
}
|
||||
case WORLD_UNAVAILABLE, BLOCKED, FAILED -> player.sendMessage(result.detail());
|
||||
}
|
||||
}
|
||||
|
||||
private void showUnlocks(Player player, PlayerTreeFellerState state) {
|
||||
player.sendMessage("Tree Feller species:");
|
||||
for (TreeSpecies species : TreeSpecies.values()) {
|
||||
if (state.isUnlocked(species)) {
|
||||
player.sendMessage("- " + species.displayName() + ": unlocked");
|
||||
} else {
|
||||
player.sendMessage("- " + species.displayName() + ": locked ("
|
||||
+ state.progress(species) + "/" + thresholds.applyAsInt(species) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendUsage(Player player) {
|
||||
player.sendMessage("Usage: /treefeller <enabled|unlocked|undo>");
|
||||
}
|
||||
|
||||
private static Optional<Boolean> parseBoolean(String value) {
|
||||
if (value.equalsIgnoreCase("on")) {
|
||||
return Optional.of(true);
|
||||
}
|
||||
if (value.equalsIgnoreCase("off")) {
|
||||
return Optional.of(false);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> values, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream().filter(value -> value.startsWith(normalized)).toList();
|
||||
}
|
||||
|
||||
private static String defaultMessage(String key) {
|
||||
return key.equals("enabled") ? "Tree Feller is enabled." : "Tree Feller is disabled.";
|
||||
}
|
||||
|
||||
private static String color(String value) {
|
||||
return value.replace('&', '\u00a7');
|
||||
}
|
||||
|
||||
private static String displayMaterial(org.bukkit.Material material) {
|
||||
String[] words = material.name().toLowerCase(Locale.ROOT).split("_");
|
||||
return java.util.Arrays.stream(words)
|
||||
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
|
||||
.collect(java.util.stream.Collectors.joining(" "));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Entry point for Tree Feller. */
|
||||
public final class TreeFellerPlugin extends JavaPlugin {
|
||||
private TreeFellerSettingsService settingsService;
|
||||
private YamlPlayerStateRepository playerStateRepository;
|
||||
private AutomaticBreakRegistry automaticBreakRegistry;
|
||||
private TreeDetector treeDetector;
|
||||
private ProgressBossBarObserver progressBossBarObserver;
|
||||
private AnimatedTreeFellingEngine fellingEngine;
|
||||
private LastFellingStore lastFellingStore;
|
||||
private TreeUndoService undoService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -17,12 +28,97 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
settings, new BukkitThresholdPersistence(this));
|
||||
Path playerStateFile = getDataFolder().toPath().resolve("players.yml");
|
||||
playerStateRepository = new YamlPlayerStateRepository(playerStateFile);
|
||||
automaticBreakRegistry = new AutomaticBreakRegistry();
|
||||
treeDetector = new BukkitTreeDetector(new TreeStructureScanner(
|
||||
settings.maxSearchBlocks(), settings.maxSearchDistance()));
|
||||
progressBossBarObserver = new ProgressBossBarObserver(
|
||||
settingsService::current,
|
||||
current -> getServer().createBossBar(
|
||||
"", current.bossBarColor(), current.bossBarStyle()),
|
||||
(task, delayTicks) -> getServer().getScheduler()
|
||||
.runTaskLater(this, task, delayTicks)::cancel);
|
||||
TreeProgressListener progressListener = new TreeProgressListener(
|
||||
treeDetector,
|
||||
playerStateRepository,
|
||||
species -> settingsService.current().threshold(species),
|
||||
automaticBreakRegistry,
|
||||
new CompositeProgressObserver(
|
||||
progressBossBarObserver,
|
||||
new TreeUnlockAnnouncement(settingsService::current)),
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
|
||||
lastFellingStore = new LastFellingStore(Clock.systemUTC());
|
||||
fellingEngine = new AnimatedTreeFellingEngine(
|
||||
(task, delayTicks) -> getServer().getScheduler()
|
||||
.runTaskLater(this, task, delayTicks)::cancel,
|
||||
automaticBreakRegistry,
|
||||
player -> playerStateRepository.load(player.getUniqueId())
|
||||
.filter(state -> state.enabled() && !state.locked())
|
||||
.isPresent(),
|
||||
lastFellingStore,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to continue animated tree felling", exception));
|
||||
TreeFellingListener fellingListener = new TreeFellingListener(
|
||||
treeDetector,
|
||||
playerStateRepository,
|
||||
automaticBreakRegistry,
|
||||
fellingEngine,
|
||||
() -> settingsService.current().animationDelayTicks(),
|
||||
player -> player.sendMessage(settingsService.current()
|
||||
.message("administratively-locked")
|
||||
.replace('&', '\u00a7')));
|
||||
getServer().getPluginManager().registerEvents(fellingEngine, this);
|
||||
getServer().getPluginManager().registerEvents(fellingListener, this);
|
||||
|
||||
undoService = new TreeUndoService(
|
||||
lastFellingStore,
|
||||
Clock.systemUTC(),
|
||||
getServer()::getWorld,
|
||||
Bukkit::createBlockData,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to restore a Tree Feller undo", exception));
|
||||
TreeFellerCommand playerCommand = new TreeFellerCommand(
|
||||
playerStateRepository,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller preference", exception),
|
||||
key -> settingsService.current().message(key),
|
||||
species -> settingsService.current().threshold(species),
|
||||
player -> undoService.undo(
|
||||
player, settingsService.current().undoWindowMinutes()));
|
||||
PluginCommand command = Objects.requireNonNull(
|
||||
getCommand("treefeller"), "treefeller command missing from plugin.yml");
|
||||
command.setExecutor(playerCommand);
|
||||
command.setTabCompleter(playerCommand);
|
||||
|
||||
TreeFellerAdminCommand adminCommand = new TreeFellerAdminCommand(
|
||||
getServer(),
|
||||
playerStateRepository,
|
||||
settingsService,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller administration", exception));
|
||||
PluginCommand admin = Objects.requireNonNull(
|
||||
getCommand("treefelleradmin"),
|
||||
"treefelleradmin command missing from plugin.yml");
|
||||
admin.setExecutor(adminCommand);
|
||||
admin.setTabCompleter(adminCommand);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (fellingEngine != null) {
|
||||
fellingEngine.close();
|
||||
}
|
||||
if (progressBossBarObserver != null) {
|
||||
progressBossBarObserver.close();
|
||||
}
|
||||
}
|
||||
|
||||
public TreeFellerSettingsService settingsService() {
|
||||
return settingsService;
|
||||
}
|
||||
@@ -30,4 +126,16 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
public YamlPlayerStateRepository playerStateRepository() {
|
||||
return playerStateRepository;
|
||||
}
|
||||
|
||||
public AutomaticBreakRegistry automaticBreakRegistry() {
|
||||
return automaticBreakRegistry;
|
||||
}
|
||||
|
||||
public TreeDetector treeDetector() {
|
||||
return treeDetector;
|
||||
}
|
||||
|
||||
public LastFellingStore lastFellingStore() {
|
||||
return lastFellingStore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.IntSupplier;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
|
||||
/** Starts automatic felling after an eligible unlocked tree chop. */
|
||||
public final class TreeFellingListener implements Listener {
|
||||
private final TreeDetector detector;
|
||||
private final PlayerStateStore states;
|
||||
private final AutomaticBreakRegistry automaticBreaks;
|
||||
private final TreeFellingStarter starter;
|
||||
private final IntSupplier animationDelay;
|
||||
private final Consumer<Player> lockedNotifier;
|
||||
|
||||
public TreeFellingListener(
|
||||
TreeDetector detector,
|
||||
PlayerStateStore states,
|
||||
AutomaticBreakRegistry automaticBreaks,
|
||||
TreeFellingStarter starter,
|
||||
IntSupplier animationDelay) {
|
||||
this(detector, states, automaticBreaks, starter, animationDelay, ignored -> { });
|
||||
}
|
||||
|
||||
public TreeFellingListener(
|
||||
TreeDetector detector,
|
||||
PlayerStateStore states,
|
||||
AutomaticBreakRegistry automaticBreaks,
|
||||
TreeFellingStarter starter,
|
||||
IntSupplier animationDelay,
|
||||
Consumer<Player> lockedNotifier) {
|
||||
this.detector = detector;
|
||||
this.states = states;
|
||||
this.automaticBreaks = automaticBreaks;
|
||||
this.starter = starter;
|
||||
this.animationDelay = animationDelay;
|
||||
this.lockedNotifier = lockedNotifier;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (event.isCancelled()
|
||||
|| player.getGameMode() != GameMode.SURVIVAL
|
||||
|| player.isSneaking()
|
||||
|| !TreeTools.isAxe(player.getInventory().getItemInMainHand().getType())
|
||||
|| automaticBreaks.isMarked(event.getBlock())) {
|
||||
return;
|
||||
}
|
||||
PlayerTreeFellerState state = states.load(player.getUniqueId())
|
||||
.orElseGet(() -> PlayerTreeFellerState.initial(
|
||||
player.getUniqueId(), player.getName()));
|
||||
if (!state.enabled()) {
|
||||
return;
|
||||
}
|
||||
if (state.locked()) {
|
||||
lockedNotifier.accept(player);
|
||||
return;
|
||||
}
|
||||
Optional<TreeStructure> detected = detector.detect(event.getBlock());
|
||||
if (detected.isEmpty() || !state.isUnlocked(detected.orElseThrow().species())) {
|
||||
return;
|
||||
}
|
||||
starter.start(
|
||||
player, event.getBlock(), detected.orElseThrow(), animationDelay.getAsInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Starts one animated tree-felling operation. */
|
||||
@FunctionalInterface
|
||||
public interface TreeFellingStarter {
|
||||
boolean start(
|
||||
Player player,
|
||||
Block initiatingBlock,
|
||||
TreeStructure tree,
|
||||
int delayTicks);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.ToIntFunction;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
|
||||
/** Records one durable progress point after an eligible manual tree-block break. */
|
||||
public final class TreeProgressListener implements Listener {
|
||||
private final TreeDetector detector;
|
||||
private final PlayerStateStore states;
|
||||
private final ToIntFunction<TreeSpecies> thresholds;
|
||||
private final AutomaticBreakRegistry automaticBreaks;
|
||||
private final ProgressObserver observer;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
|
||||
public TreeProgressListener(
|
||||
TreeDetector detector,
|
||||
PlayerStateStore states,
|
||||
ToIntFunction<TreeSpecies> thresholds,
|
||||
AutomaticBreakRegistry automaticBreaks,
|
||||
ProgressObserver observer,
|
||||
Consumer<Exception> failureHandler) {
|
||||
this.detector = detector;
|
||||
this.states = states;
|
||||
this.thresholds = thresholds;
|
||||
this.automaticBreaks = automaticBreaks;
|
||||
this.observer = observer;
|
||||
this.failureHandler = failureHandler;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (event.isCancelled()
|
||||
|| player.getGameMode() != GameMode.SURVIVAL
|
||||
|| automaticBreaks.isMarked(event.getBlock())) {
|
||||
return;
|
||||
}
|
||||
Optional<TreeSpecies> classified = TreeTaxonomy.directSpecies(event.getBlock().getType());
|
||||
if (classified.isEmpty()
|
||||
&& event.getBlock().getType() == org.bukkit.Material.MUSHROOM_STEM) {
|
||||
classified = detector.detect(event.getBlock()).map(TreeStructure::species);
|
||||
}
|
||||
if (classified.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
TreeSpecies species = classified.orElseThrow();
|
||||
PlayerTreeFellerState state = states.load(player.getUniqueId())
|
||||
.orElseGet(() -> PlayerTreeFellerState.initial(
|
||||
player.getUniqueId(), player.getName()));
|
||||
state = state.observeName(player.getName());
|
||||
ProgressUpdate update = TreeProgressTracker.record(
|
||||
state, species, thresholds.applyAsInt(species));
|
||||
try {
|
||||
states.save(update.state());
|
||||
observer.onProgress(player, update);
|
||||
} catch (IOException exception) {
|
||||
failureHandler.accept(exception);
|
||||
player.sendMessage("Tree Feller could not save your progress; no progress was applied.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Species progress rules independent of Bukkit event handling. */
|
||||
public final class TreeProgressTracker {
|
||||
private TreeProgressTracker() {
|
||||
}
|
||||
|
||||
public static ProgressUpdate record(
|
||||
PlayerTreeFellerState state, TreeSpecies species, int threshold) {
|
||||
PlayerTreeFellerState incremented = state.incrementProgress(species);
|
||||
boolean newlyUnlocked = !state.isUnlocked(species)
|
||||
&& incremented.progress(species) >= threshold;
|
||||
PlayerTreeFellerState result = newlyUnlocked
|
||||
? incremented.withUnlocked(species, true)
|
||||
: incremented;
|
||||
return new ProgressUpdate(
|
||||
result,
|
||||
species,
|
||||
result.progress(species),
|
||||
threshold,
|
||||
newlyUnlocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A validated species and its connected trunk blocks. */
|
||||
public record TreeStructure(TreeSpecies species, List<BlockPoint> trunkBlocks) {
|
||||
public TreeStructure {
|
||||
trunkBlocks = List.copyOf(trunkBlocks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
|
||||
/** Iterative, upward-only tree detector with explicit safety bounds. */
|
||||
public final class TreeStructureScanner {
|
||||
private final int maxBlocks;
|
||||
private final int maxDistance;
|
||||
|
||||
public TreeStructureScanner(int maxBlocks, int maxDistance) {
|
||||
if (maxBlocks < 1 || maxDistance < 1) {
|
||||
throw new IllegalArgumentException("search bounds must be positive");
|
||||
}
|
||||
this.maxBlocks = maxBlocks;
|
||||
this.maxDistance = maxDistance;
|
||||
}
|
||||
|
||||
public Optional<TreeStructure> scan(BlockAccess blocks, BlockPoint start) {
|
||||
Set<TreeSpecies> candidates = TreeTaxonomy.candidates(blocks.materialAt(start));
|
||||
if (candidates.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
TreeStructure match = null;
|
||||
for (TreeSpecies candidate : candidates) {
|
||||
Optional<TreeStructure> detected = scanCandidate(blocks, start, candidate);
|
||||
if (detected.isPresent()) {
|
||||
if (match != null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
match = detected.orElseThrow();
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(match);
|
||||
}
|
||||
|
||||
private Optional<TreeStructure> scanCandidate(
|
||||
BlockAccess blocks, BlockPoint start, TreeSpecies species) {
|
||||
Queue<BlockPoint> pending = new ArrayDeque<>();
|
||||
LinkedHashSet<BlockPoint> visited = new LinkedHashSet<>();
|
||||
pending.add(start);
|
||||
visited.add(start);
|
||||
|
||||
while (!pending.isEmpty()) {
|
||||
BlockPoint current = pending.remove();
|
||||
for (int deltaY = 0; deltaY <= 1; deltaY++) {
|
||||
for (int deltaX = -1; deltaX <= 1; deltaX++) {
|
||||
for (int deltaZ = -1; deltaZ <= 1; deltaZ++) {
|
||||
if (deltaX == 0 && deltaY == 0 && deltaZ == 0) {
|
||||
continue;
|
||||
}
|
||||
BlockPoint next = current.add(deltaX, deltaY, deltaZ);
|
||||
if (next.chebyshevDistance(start) > maxDistance
|
||||
|| visited.contains(next)
|
||||
|| !TreeTaxonomy.isTrunk(blocks.materialAt(next), species)) {
|
||||
continue;
|
||||
}
|
||||
if (visited.size() >= maxBlocks) {
|
||||
return Optional.empty();
|
||||
}
|
||||
visited.add(next);
|
||||
pending.add(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFoliage(blocks, visited, species)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new TreeStructure(species, new ArrayList<>(visited)));
|
||||
}
|
||||
|
||||
private boolean hasFoliage(
|
||||
BlockAccess blocks, Set<BlockPoint> trunkBlocks, TreeSpecies species) {
|
||||
for (BlockPoint trunk : trunkBlocks) {
|
||||
for (int deltaY = -1; deltaY <= 1; deltaY++) {
|
||||
for (int deltaX = -1; deltaX <= 1; deltaX++) {
|
||||
for (int deltaZ = -1; deltaZ <= 1; deltaZ++) {
|
||||
if (TreeTaxonomy.isFoliage(
|
||||
blocks.materialAt(trunk.add(deltaX, deltaY, deltaZ)), species)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Maps Spigot block materials to supported natural tree families. */
|
||||
public final class TreeTaxonomy {
|
||||
private static final Map<Material, TreeSpecies> DIRECT_TRUNKS = Map.ofEntries(
|
||||
Map.entry(Material.OAK_LOG, TreeSpecies.OAK),
|
||||
Map.entry(Material.SPRUCE_LOG, TreeSpecies.SPRUCE),
|
||||
Map.entry(Material.BIRCH_LOG, TreeSpecies.BIRCH),
|
||||
Map.entry(Material.JUNGLE_LOG, TreeSpecies.JUNGLE),
|
||||
Map.entry(Material.ACACIA_LOG, TreeSpecies.ACACIA),
|
||||
Map.entry(Material.DARK_OAK_LOG, TreeSpecies.DARK_OAK),
|
||||
Map.entry(Material.MANGROVE_LOG, TreeSpecies.MANGROVE),
|
||||
Map.entry(Material.CHERRY_LOG, TreeSpecies.CHERRY),
|
||||
Map.entry(Material.PALE_OAK_LOG, TreeSpecies.PALE_OAK),
|
||||
Map.entry(Material.CRIMSON_STEM, TreeSpecies.CRIMSON),
|
||||
Map.entry(Material.WARPED_STEM, TreeSpecies.WARPED));
|
||||
|
||||
private static final Map<TreeSpecies, Set<Material>> FOLIAGE = createFoliage();
|
||||
|
||||
private TreeTaxonomy() {
|
||||
}
|
||||
|
||||
public static Optional<TreeSpecies> directSpecies(Material material) {
|
||||
return Optional.ofNullable(DIRECT_TRUNKS.get(material));
|
||||
}
|
||||
|
||||
public static Set<TreeSpecies> candidates(Material material) {
|
||||
Optional<TreeSpecies> direct = directSpecies(material);
|
||||
if (direct.isPresent()) {
|
||||
return Set.of(direct.orElseThrow());
|
||||
}
|
||||
if (material == Material.MUSHROOM_STEM) {
|
||||
return EnumSet.of(TreeSpecies.RED_MUSHROOM, TreeSpecies.BROWN_MUSHROOM);
|
||||
}
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
public static boolean isTrunk(Material material, TreeSpecies species) {
|
||||
if (species == TreeSpecies.RED_MUSHROOM || species == TreeSpecies.BROWN_MUSHROOM) {
|
||||
return material == Material.MUSHROOM_STEM;
|
||||
}
|
||||
return directSpecies(material).filter(species::equals).isPresent();
|
||||
}
|
||||
|
||||
public static boolean isFoliage(Material material, TreeSpecies species) {
|
||||
return FOLIAGE.getOrDefault(species, Set.of()).contains(material);
|
||||
}
|
||||
|
||||
private static Map<TreeSpecies, Set<Material>> createFoliage() {
|
||||
EnumMap<TreeSpecies, Set<Material>> foliage = new EnumMap<>(TreeSpecies.class);
|
||||
foliage.put(TreeSpecies.OAK, Set.of(Material.OAK_LEAVES, Material.AZALEA_LEAVES, Material.FLOWERING_AZALEA_LEAVES));
|
||||
foliage.put(TreeSpecies.SPRUCE, Set.of(Material.SPRUCE_LEAVES));
|
||||
foliage.put(TreeSpecies.BIRCH, Set.of(Material.BIRCH_LEAVES));
|
||||
foliage.put(TreeSpecies.JUNGLE, Set.of(Material.JUNGLE_LEAVES));
|
||||
foliage.put(TreeSpecies.ACACIA, Set.of(Material.ACACIA_LEAVES));
|
||||
foliage.put(TreeSpecies.DARK_OAK, Set.of(Material.DARK_OAK_LEAVES));
|
||||
foliage.put(TreeSpecies.MANGROVE, Set.of(Material.MANGROVE_LEAVES));
|
||||
foliage.put(TreeSpecies.CHERRY, Set.of(Material.CHERRY_LEAVES));
|
||||
foliage.put(TreeSpecies.PALE_OAK, Set.of(Material.PALE_OAK_LEAVES));
|
||||
foliage.put(TreeSpecies.CRIMSON, Set.of(Material.NETHER_WART_BLOCK));
|
||||
foliage.put(TreeSpecies.WARPED, Set.of(Material.WARPED_WART_BLOCK));
|
||||
foliage.put(TreeSpecies.RED_MUSHROOM, Set.of(Material.RED_MUSHROOM_BLOCK));
|
||||
foliage.put(TreeSpecies.BROWN_MUSHROOM, Set.of(Material.BROWN_MUSHROOM_BLOCK));
|
||||
return Map.copyOf(foliage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Tool classification shared by progress and felling. */
|
||||
public final class TreeTools {
|
||||
private static final Set<Material> AXES = EnumSet.of(
|
||||
Material.WOODEN_AXE,
|
||||
Material.STONE_AXE,
|
||||
Material.COPPER_AXE,
|
||||
Material.IRON_AXE,
|
||||
Material.GOLDEN_AXE,
|
||||
Material.DIAMOND_AXE,
|
||||
Material.NETHERITE_AXE);
|
||||
|
||||
private TreeTools() {
|
||||
}
|
||||
|
||||
public static boolean isAxe(Material material) {
|
||||
return AXES.contains(material);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
/** Preflights, accounts for, and atomically restores the latest felling. */
|
||||
public final class TreeUndoService {
|
||||
private final LastFellingStore records;
|
||||
private final Clock clock;
|
||||
private final Function<UUID, World> worlds;
|
||||
private final Function<String, BlockData> blockDataParser;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
|
||||
public TreeUndoService(
|
||||
LastFellingStore records,
|
||||
Clock clock,
|
||||
Function<UUID, World> worlds,
|
||||
Function<String, BlockData> blockDataParser,
|
||||
Consumer<Exception> failureHandler) {
|
||||
this.records = records;
|
||||
this.clock = clock;
|
||||
this.worlds = worlds;
|
||||
this.blockDataParser = blockDataParser;
|
||||
this.failureHandler = failureHandler;
|
||||
}
|
||||
|
||||
public UndoResult undo(Player player, int windowMinutes) {
|
||||
if (windowMinutes < 1) {
|
||||
throw new IllegalArgumentException("windowMinutes must be positive");
|
||||
}
|
||||
UUID playerId = player.getUniqueId();
|
||||
FellingRecord record = records.get(playerId).orElse(null);
|
||||
if (record == null) {
|
||||
return UndoResult.of(UndoStatus.NONE, "No felling is available");
|
||||
}
|
||||
Instant expiresAt = record.felledAt().plus(Duration.ofMinutes(windowMinutes));
|
||||
if (Instant.now(clock).isAfter(expiresAt)) {
|
||||
records.remove(playerId);
|
||||
return UndoResult.of(UndoStatus.EXPIRED, "The undo window has expired");
|
||||
}
|
||||
|
||||
Preflight preflight;
|
||||
try {
|
||||
preflight = preflight(player.getInventory(), record);
|
||||
} catch (RuntimeException exception) {
|
||||
failureHandler.accept(exception);
|
||||
return UndoResult.of(UndoStatus.FAILED, "Undo preflight failed");
|
||||
}
|
||||
if (preflight.failure != null) {
|
||||
return preflight.failure;
|
||||
}
|
||||
|
||||
List<Restoration> changed = new ArrayList<>();
|
||||
try {
|
||||
preflight.withdrawal.apply(player.getInventory());
|
||||
for (Restoration restoration : preflight.restorations) {
|
||||
restoration.block.setBlockData(restoration.target, false);
|
||||
changed.add(restoration);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
rollback(player.getInventory(), preflight.withdrawal, changed, exception);
|
||||
return UndoResult.of(UndoStatus.FAILED, "Undo restoration failed and was rolled back");
|
||||
}
|
||||
|
||||
records.remove(playerId);
|
||||
return UndoResult.of(UndoStatus.SUCCESS, "Tree restored");
|
||||
}
|
||||
|
||||
private Preflight preflight(PlayerInventory inventory, FellingRecord record) {
|
||||
List<Restoration> restorations = new ArrayList<>();
|
||||
EnumMap<Material, Integer> required = new EnumMap<>(Material.class);
|
||||
for (FelledBlockSnapshot snapshot : record.blocks()) {
|
||||
World world = worlds.apply(snapshot.worldId());
|
||||
if (world == null) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.WORLD_UNAVAILABLE,
|
||||
"World " + snapshot.worldName() + " is unavailable"));
|
||||
}
|
||||
if (!world.isChunkLoaded(snapshot.x() >> 4, snapshot.z() >> 4)) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.WORLD_UNAVAILABLE,
|
||||
"A required chunk in " + snapshot.worldName() + " is not loaded"));
|
||||
}
|
||||
Block block = world.getBlockAt(snapshot.x(), snapshot.y(), snapshot.z());
|
||||
if (!block.isEmpty()) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.BLOCKED,
|
||||
"Restoration position is occupied at "
|
||||
+ snapshot.x() + "," + snapshot.y() + "," + snapshot.z()));
|
||||
}
|
||||
BlockData target = blockDataParser.apply(snapshot.blockData());
|
||||
restorations.add(new Restoration(block, block.getBlockData().clone(), target));
|
||||
required.merge(snapshot.material(), 1, Math::addExact);
|
||||
}
|
||||
|
||||
InventoryWithdrawal withdrawal = InventoryWithdrawal.plan(inventory, required);
|
||||
if (!withdrawal.missing.isEmpty()) {
|
||||
return Preflight.failure(UndoResult.missing(withdrawal.missing));
|
||||
}
|
||||
return new Preflight(List.copyOf(restorations), withdrawal, null);
|
||||
}
|
||||
|
||||
private void rollback(
|
||||
PlayerInventory inventory,
|
||||
InventoryWithdrawal withdrawal,
|
||||
List<Restoration> changed,
|
||||
RuntimeException originalFailure) {
|
||||
RuntimeException failure = originalFailure;
|
||||
for (int index = changed.size() - 1; index >= 0; index--) {
|
||||
Restoration restoration = changed.get(index);
|
||||
try {
|
||||
restoration.block.setBlockData(restoration.prior, false);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
withdrawal.rollback(inventory);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
failureHandler.accept(failure);
|
||||
}
|
||||
|
||||
private record Restoration(Block block, BlockData prior, BlockData target) {
|
||||
}
|
||||
|
||||
private record Preflight(
|
||||
List<Restoration> restorations,
|
||||
InventoryWithdrawal withdrawal,
|
||||
UndoResult failure) {
|
||||
private static Preflight failure(UndoResult result) {
|
||||
return new Preflight(List.of(), InventoryWithdrawal.empty(), result);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InventoryWithdrawal {
|
||||
private final Map<Integer, ItemStack> originals;
|
||||
private final Map<Integer, ItemStack> replacements;
|
||||
private final Map<Material, Integer> missing;
|
||||
|
||||
private InventoryWithdrawal(
|
||||
Map<Integer, ItemStack> originals,
|
||||
Map<Integer, ItemStack> replacements,
|
||||
Map<Material, Integer> missing) {
|
||||
this.originals = originals;
|
||||
this.replacements = replacements;
|
||||
this.missing = missing;
|
||||
}
|
||||
|
||||
private static InventoryWithdrawal plan(
|
||||
PlayerInventory inventory, Map<Material, Integer> required) {
|
||||
ItemStack[] contents = inventory.getStorageContents();
|
||||
EnumMap<Material, Integer> remaining = new EnumMap<>(Material.class);
|
||||
remaining.putAll(required);
|
||||
Map<Integer, ItemStack> originals = new HashMap<>();
|
||||
Map<Integer, ItemStack> replacements = new HashMap<>();
|
||||
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
ItemStack stack = contents[index];
|
||||
if (stack == null || isAir(stack.getType())) {
|
||||
continue;
|
||||
}
|
||||
int needed = remaining.getOrDefault(stack.getType(), 0);
|
||||
if (needed <= 0) {
|
||||
continue;
|
||||
}
|
||||
int taken = Math.min(needed, stack.getAmount());
|
||||
originals.put(index, stack.clone());
|
||||
if (taken == stack.getAmount()) {
|
||||
replacements.put(index, null);
|
||||
} else {
|
||||
ItemStack reduced = stack.clone();
|
||||
reduced.setAmount(stack.getAmount() - taken);
|
||||
replacements.put(index, reduced);
|
||||
}
|
||||
remaining.put(stack.getType(), needed - taken);
|
||||
}
|
||||
|
||||
EnumMap<Material, Integer> missing = new EnumMap<>(Material.class);
|
||||
remaining.forEach((material, count) -> {
|
||||
if (count > 0) {
|
||||
missing.put(material, count);
|
||||
}
|
||||
});
|
||||
return new InventoryWithdrawal(originals, replacements, Map.copyOf(missing));
|
||||
}
|
||||
|
||||
private static InventoryWithdrawal empty() {
|
||||
return new InventoryWithdrawal(Map.of(), Map.of(), Map.of());
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR
|
||||
|| material == Material.CAVE_AIR
|
||||
|| material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private void apply(PlayerInventory inventory) {
|
||||
replacements.forEach(inventory::setItem);
|
||||
}
|
||||
|
||||
private void rollback(PlayerInventory inventory) {
|
||||
originals.forEach(inventory::setItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Presents the one-time earned unlock title and safety guidance. */
|
||||
public final class TreeUnlockAnnouncement implements ProgressObserver {
|
||||
private final Supplier<TreeFellerSettings> settings;
|
||||
|
||||
public TreeUnlockAnnouncement(Supplier<TreeFellerSettings> settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(Player player, ProgressUpdate update) {
|
||||
if (!update.newlyUnlocked()) {
|
||||
return;
|
||||
}
|
||||
TreeFellerSettings current = settings.get();
|
||||
player.sendTitle(
|
||||
format(current.titleText(), update),
|
||||
format(current.subtitleText(), update),
|
||||
current.titleFadeInTicks(),
|
||||
current.titleStayTicks(),
|
||||
current.titleFadeOutTicks());
|
||||
player.sendMessage(format(current.message("unlock-guidance"), update));
|
||||
}
|
||||
|
||||
private String format(String template, ProgressUpdate update) {
|
||||
return template
|
||||
.replace("{species}", update.species().displayName())
|
||||
.replace("{progress}", Long.toString(update.progress()))
|
||||
.replace("{threshold}", Integer.toString(update.threshold()))
|
||||
.replace('&', '\u00a7');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Player-command boundary for undo execution. */
|
||||
@FunctionalInterface
|
||||
public interface UndoAction {
|
||||
UndoResult undo(Player player);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Complete result of an undo request without partial-success semantics. */
|
||||
public record UndoResult(
|
||||
UndoStatus status,
|
||||
Map<Material, Integer> missingMaterials,
|
||||
String detail) {
|
||||
public UndoResult {
|
||||
missingMaterials = Map.copyOf(missingMaterials);
|
||||
}
|
||||
|
||||
public static UndoResult of(UndoStatus status, String detail) {
|
||||
return new UndoResult(status, Map.of(), detail);
|
||||
}
|
||||
|
||||
public static UndoResult missing(Map<Material, Integer> missing) {
|
||||
return new UndoResult(
|
||||
UndoStatus.MISSING_MATERIALS, missing, "Required replacement materials are missing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Observable outcomes of an atomic tree undo request. */
|
||||
public enum UndoStatus {
|
||||
SUCCESS,
|
||||
NONE,
|
||||
EXPIRED,
|
||||
WORLD_UNAVAILABLE,
|
||||
BLOCKED,
|
||||
MISSING_MATERIALS,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
/** Stable runtime identity for a world block. */
|
||||
public record WorldBlockKey(UUID worldId, int x, int y, int z) {
|
||||
public static WorldBlockKey from(Block block) {
|
||||
return new WorldBlockKey(
|
||||
block.getWorld().getUID(), block.getX(), block.getY(), block.getZ());
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
/** UUID-keyed YAML persistence that retains fields it does not own. */
|
||||
public final class YamlPlayerStateRepository {
|
||||
public final class YamlPlayerStateRepository implements PlayerStateCatalog {
|
||||
private final Path file;
|
||||
private final YamlConfiguration document;
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class YamlPlayerStateRepository {
|
||||
: new YamlConfiguration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlayerTreeFellerState> load(UUID playerId) {
|
||||
ConfigurationSection record = document.getConfigurationSection(path(playerId));
|
||||
if (record == null) {
|
||||
@@ -42,6 +43,7 @@ public final class YamlPlayerStateRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlayerTreeFellerState> loadAll() {
|
||||
ConfigurationSection players = document.getConfigurationSection("players");
|
||||
if (players == null) {
|
||||
@@ -59,6 +61,7 @@ public final class YamlPlayerStateRepository {
|
||||
return List.copyOf(states);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState state) throws IOException {
|
||||
String root = path(state.playerId());
|
||||
document.set(root + ".latest-name", state.latestName());
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
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 static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AnimatedTreeFellingEngineTest {
|
||||
@Test
|
||||
void breaksRemainingTrunkBlocksBottomToTopAtTheConfiguredInterval() {
|
||||
TestTree tree = new TestTree();
|
||||
BlockPoint origin = new BlockPoint(0, 0, 0);
|
||||
tree.add(origin, Material.OAK_LOG);
|
||||
tree.add(new BlockPoint(0, 2, 0), Material.OAK_LOG);
|
||||
tree.add(new BlockPoint(0, 1, 0), Material.OAK_LOG);
|
||||
Player player = tree.player();
|
||||
QueueScheduler scheduler = new QueueScheduler();
|
||||
List<FelledBlockSnapshot> completed = new ArrayList<>();
|
||||
List<Integer> brokenHeights = new ArrayList<>();
|
||||
when(player.breakBlock(any(Block.class))).thenAnswer(invocation -> {
|
||||
Block block = invocation.getArgument(0);
|
||||
brokenHeights.add(block.getY());
|
||||
return true;
|
||||
});
|
||||
AnimatedTreeFellingEngine engine = new AnimatedTreeFellingEngine(
|
||||
scheduler,
|
||||
new AutomaticBreakRegistry(),
|
||||
ignored -> true,
|
||||
(ignored, snapshots) -> completed.addAll(snapshots),
|
||||
ignored -> { });
|
||||
|
||||
boolean started = engine.start(
|
||||
player,
|
||||
tree.block(origin),
|
||||
new TreeStructure(TreeSpecies.OAK, List.of(
|
||||
origin, new BlockPoint(0, 2, 0), new BlockPoint(0, 1, 0))),
|
||||
2);
|
||||
scheduler.runAll();
|
||||
|
||||
assertTrue(started);
|
||||
assertEquals(List.of(65, 66), brokenHeights);
|
||||
assertEquals(List.of(2L, 2L), scheduler.delays);
|
||||
assertEquals(3, completed.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopsAfterAProtectionCancellationAndDoesNotRunAnOverlappingFelling() {
|
||||
TestTree tree = new TestTree();
|
||||
BlockPoint origin = new BlockPoint(0, 0, 0);
|
||||
tree.add(origin, Material.OAK_LOG);
|
||||
tree.add(new BlockPoint(0, 1, 0), Material.OAK_LOG);
|
||||
tree.add(new BlockPoint(0, 2, 0), Material.OAK_LOG);
|
||||
QueueScheduler scheduler = new QueueScheduler();
|
||||
when(tree.player().breakBlock(any(Block.class))).thenReturn(false);
|
||||
AnimatedTreeFellingEngine engine = new AnimatedTreeFellingEngine(
|
||||
scheduler,
|
||||
new AutomaticBreakRegistry(),
|
||||
ignored -> true,
|
||||
(ignored, snapshots) -> { },
|
||||
ignored -> { });
|
||||
TreeStructure structure = new TreeStructure(
|
||||
TreeSpecies.OAK,
|
||||
List.of(origin, new BlockPoint(0, 1, 0), new BlockPoint(0, 2, 0)));
|
||||
|
||||
assertTrue(engine.start(tree.player(), tree.block(origin), structure, 2));
|
||||
assertFalse(engine.start(tree.player(), tree.block(origin), structure, 2));
|
||||
scheduler.runAll();
|
||||
|
||||
org.mockito.Mockito.verify(tree.player(), org.mockito.Mockito.times(1))
|
||||
.breakBlock(any(Block.class));
|
||||
}
|
||||
|
||||
private static final class QueueScheduler implements DelayedTaskScheduler {
|
||||
private final Queue<Runnable> tasks = new ArrayDeque<>();
|
||||
private final List<Long> delays = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public ScheduledHandle schedule(Runnable task, long delayTicks) {
|
||||
tasks.add(task);
|
||||
delays.add(delayTicks);
|
||||
return () -> tasks.remove(task);
|
||||
}
|
||||
|
||||
void runAll() {
|
||||
while (!tasks.isEmpty()) {
|
||||
tasks.remove().run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TestTree {
|
||||
private final UUID worldId = UUID.randomUUID();
|
||||
private final World world = mock(World.class);
|
||||
private final Player player = mock(Player.class);
|
||||
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
private final Map<BlockPoint, Block> blocks = new HashMap<>();
|
||||
|
||||
private TestTree() {
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
|
||||
}
|
||||
|
||||
void add(BlockPoint point, Material material) {
|
||||
Block block = mock(Block.class);
|
||||
BlockData data = mock(BlockData.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getX()).thenReturn(point.x());
|
||||
when(block.getY()).thenReturn(64 + point.y());
|
||||
when(block.getZ()).thenReturn(point.z());
|
||||
when(block.getType()).thenReturn(material);
|
||||
when(block.getBlockData()).thenReturn(data);
|
||||
when(data.getAsString()).thenReturn(material.name().toLowerCase(java.util.Locale.ROOT));
|
||||
blocks.put(point, block);
|
||||
}
|
||||
|
||||
Block block(BlockPoint point) {
|
||||
Block origin = blocks.get(new BlockPoint(0, 0, 0));
|
||||
blocks.forEach((relative, block) -> when(origin.getRelative(
|
||||
relative.x(), relative.y(), relative.z())).thenReturn(block));
|
||||
return blocks.get(point);
|
||||
}
|
||||
|
||||
Player player() {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PlayerTargetResolverTest {
|
||||
@Test
|
||||
void rejectsAnAmbiguousHistoricalNameButAcceptsAnAuthoritativeUuid() {
|
||||
UUID firstId = UUID.randomUUID();
|
||||
UUID secondId = UUID.randomUUID();
|
||||
PlayerTreeFellerState first = PlayerTreeFellerState.initial(firstId, "First")
|
||||
.observeName("Shared");
|
||||
PlayerTreeFellerState second = PlayerTreeFellerState.initial(secondId, "Second")
|
||||
.observeName("Shared");
|
||||
PlayerStateCatalog states = new Catalog(List.of(first, second));
|
||||
Server server = mock(Server.class);
|
||||
when(server.getOnlinePlayers()).thenReturn(List.of());
|
||||
PlayerTargetResolver resolver = new PlayerTargetResolver(server, states);
|
||||
|
||||
assertEquals(TargetResolutionStatus.AMBIGUOUS, resolver.resolve("Shared").status());
|
||||
TargetResolution byUuid = resolver.resolve(firstId.toString());
|
||||
assertEquals(TargetResolutionStatus.FOUND, byUuid.status());
|
||||
assertEquals(firstId, byUuid.target().playerId());
|
||||
}
|
||||
|
||||
private record Catalog(List<PlayerTreeFellerState> values) implements PlayerStateCatalog {
|
||||
@Override
|
||||
public Optional<PlayerTreeFellerState> load(UUID playerId) {
|
||||
return values.stream().filter(state -> state.playerId().equals(playerId)).findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlayerTreeFellerState> loadAll() {
|
||||
return values;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState state) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.boss.BossBar;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProgressBossBarObserverTest {
|
||||
@Test
|
||||
void displaysNumericProgressAndHidesItAfterTheConfiguredIdlePeriod() throws Exception {
|
||||
TreeFellerSettings settings = defaults();
|
||||
BossBar bar = mock(BossBar.class);
|
||||
Player player = mock(Player.class);
|
||||
UUID playerId = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
AtomicReference<Runnable> timeout = new AtomicReference<>();
|
||||
AtomicReference<Long> delay = new AtomicReference<>();
|
||||
ProgressBossBarObserver observer = new ProgressBossBarObserver(
|
||||
() -> settings,
|
||||
ignored -> bar,
|
||||
(task, ticks) -> {
|
||||
timeout.set(task);
|
||||
delay.set(ticks);
|
||||
return () -> { };
|
||||
});
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withProgress(TreeSpecies.OAK, 25);
|
||||
|
||||
observer.onProgress(player, new ProgressUpdate(
|
||||
state, TreeSpecies.OAK, 25, 100, false));
|
||||
|
||||
verify(bar).setTitle("§aOak: 25/100");
|
||||
verify(bar).setProgress(0.25D);
|
||||
verify(bar).addPlayer(player);
|
||||
verify(bar).setVisible(true);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(100L, delay.get());
|
||||
timeout.get().run();
|
||||
verify(bar).removePlayer(player);
|
||||
verify(bar).setVisible(false);
|
||||
}
|
||||
|
||||
private TreeFellerSettings defaults() throws Exception {
|
||||
try (InputStreamReader reader = new InputStreamReader(
|
||||
getClass().getClassLoader().getResourceAsStream("config.yml"),
|
||||
StandardCharsets.UTF_8)) {
|
||||
return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
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 static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.startsWith;
|
||||
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.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeFellerAdminCommandTest {
|
||||
@Test
|
||||
void grantsAndResetsOnlyTheNamedSpeciesWithDistinctOnlineMessages() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player target = mock(Player.class);
|
||||
when(target.getUniqueId()).thenReturn(playerId);
|
||||
when(target.getName()).thenReturn("Target");
|
||||
Server server = serverWith(target);
|
||||
InMemoryCatalog states = new InMemoryCatalog();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Target")
|
||||
.withProgress(TreeSpecies.OAK, 40)
|
||||
.withUnlocked(TreeSpecies.BIRCH, true);
|
||||
TreeFellerAdminCommand handler = handler(server, states);
|
||||
CommandSender administrator = administrator();
|
||||
|
||||
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
|
||||
new String[] {"player", "Target", "tree", "oak", "grant"});
|
||||
|
||||
assertTrue(states.state.isUnlocked(TreeSpecies.OAK));
|
||||
assertTrue(states.state.isUnlocked(TreeSpecies.BIRCH));
|
||||
assertEquals(40, states.state.progress(TreeSpecies.OAK));
|
||||
verify(target).sendMessage(contains("granted"));
|
||||
verify(target, never()).sendTitle(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt());
|
||||
|
||||
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
|
||||
new String[] {"player", "Target", "tree", "oak", "reset"});
|
||||
|
||||
assertFalse(states.state.isUnlocked(TreeSpecies.OAK));
|
||||
assertEquals(0, states.state.progress(TreeSpecies.OAK));
|
||||
assertTrue(states.state.isUnlocked(TreeSpecies.BIRCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockSuppressesAllAutomaticFellingWithoutErasingPreferenceOrProgress() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player target = mock(Player.class);
|
||||
when(target.getUniqueId()).thenReturn(playerId);
|
||||
when(target.getName()).thenReturn("Target");
|
||||
InMemoryCatalog states = new InMemoryCatalog();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Target")
|
||||
.withProgress(TreeSpecies.OAK, 12);
|
||||
TreeFellerAdminCommand handler = handler(serverWith(target), states);
|
||||
|
||||
handler.onCommand(administrator(), mock(Command.class), "treefelleradmin",
|
||||
new String[] {"player", "Target", "locked", "on"});
|
||||
|
||||
assertTrue(states.state.locked());
|
||||
assertTrue(states.state.enabled());
|
||||
assertEquals(12, states.state.progress(TreeSpecies.OAK));
|
||||
verify(target).sendMessage(contains("locked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesAndPersistsAValidatedSpeciesThreshold() throws Exception {
|
||||
InMemoryCatalog states = new InMemoryCatalog();
|
||||
TreeFellerSettingsService settings = new TreeFellerSettingsService(
|
||||
defaults(), (species, threshold) -> { });
|
||||
TreeFellerAdminCommand handler = new TreeFellerAdminCommand(
|
||||
mock(Server.class), states, settings, ignored -> { });
|
||||
CommandSender administrator = administrator();
|
||||
|
||||
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
|
||||
new String[] {"threshold", "dark-oak", "25"});
|
||||
|
||||
assertEquals(25, settings.current().threshold(TreeSpecies.DARK_OAK));
|
||||
verify(administrator).sendMessage(contains("Dark Oak"));
|
||||
verify(administrator).sendMessage(contains("25"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autocompleteIsPermissionAwareAndPositionSpecific() throws Exception {
|
||||
Player target = mock(Player.class);
|
||||
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(target.getName()).thenReturn("Target");
|
||||
TreeFellerAdminCommand handler = handler(serverWith(target), new InMemoryCatalog());
|
||||
CommandSender denied = mock(CommandSender.class);
|
||||
CommandSender administrator = administrator();
|
||||
|
||||
assertEquals(List.of(), handler.onTabComplete(
|
||||
denied, mock(Command.class), "treefelleradmin", new String[] {""}));
|
||||
assertEquals(List.of("player"), handler.onTabComplete(
|
||||
administrator, mock(Command.class), "treefelleradmin", new String[] {"p"}));
|
||||
assertEquals(List.of("Target"), handler.onTabComplete(
|
||||
administrator, mock(Command.class), "treefelleradmin", new String[] {"player", "T"}));
|
||||
assertEquals(List.of("grant"), handler.onTabComplete(
|
||||
administrator,
|
||||
mock(Command.class),
|
||||
"treefelleradmin",
|
||||
new String[] {"player", "Target", "tree", "oak", "g"}));
|
||||
}
|
||||
|
||||
private TreeFellerAdminCommand handler(Server server, InMemoryCatalog states) throws Exception {
|
||||
return new TreeFellerAdminCommand(
|
||||
server,
|
||||
states,
|
||||
new TreeFellerSettingsService(defaults(), (species, threshold) -> { }),
|
||||
ignored -> { });
|
||||
}
|
||||
|
||||
private Server serverWith(Player player) {
|
||||
Server server = mock(Server.class);
|
||||
Collection<Player> players = List.of(player);
|
||||
when(server.getOnlinePlayers()).thenAnswer(ignored -> players);
|
||||
when(server.getPlayer(player.getUniqueId())).thenReturn(player);
|
||||
return server;
|
||||
}
|
||||
|
||||
private CommandSender administrator() {
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("treefeller.admin")).thenReturn(true);
|
||||
return sender;
|
||||
}
|
||||
|
||||
private TreeFellerSettings defaults() throws Exception {
|
||||
try (InputStreamReader reader = new InputStreamReader(
|
||||
getClass().getClassLoader().getResourceAsStream("config.yml"),
|
||||
StandardCharsets.UTF_8)) {
|
||||
return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemoryCatalog implements PlayerStateCatalog {
|
||||
private PlayerTreeFellerState state;
|
||||
|
||||
@Override
|
||||
public Optional<PlayerTreeFellerState> load(UUID playerId) {
|
||||
return state != null && state.playerId().equals(playerId)
|
||||
? Optional.of(state)
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlayerTreeFellerState> loadAll() {
|
||||
return state == null ? List.of() : new ArrayList<>(List.of(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState changed) {
|
||||
state = changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeFellerCommandTest {
|
||||
@Test
|
||||
void persistentlyDisablesTheIssuingPlayerWithoutChangingUnlocks() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
InMemoryStateStore states = new InMemoryStateStore();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withUnlocked(TreeSpecies.OAK, true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(states, ignored -> { });
|
||||
|
||||
boolean handled = handler.onCommand(
|
||||
player, mock(Command.class), "treefeller", new String[] {"enabled", "off"});
|
||||
|
||||
assertFalse(states.state.enabled());
|
||||
assertEquals(true, states.state.isUnlocked(TreeSpecies.OAK));
|
||||
assertEquals(1, states.saveCount);
|
||||
verify(player).sendMessage(contains("disabled"));
|
||||
assertEquals(true, handled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheSavedPreferenceAndAdministrativeOverrideWithoutChangingState() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
InMemoryStateStore states = new InMemoryStateStore();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Player").withLocked(true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(states, ignored -> { });
|
||||
|
||||
handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"enabled"});
|
||||
|
||||
verify(player).sendMessage(contains("enabled"));
|
||||
verify(player).sendMessage(contains("administrative lock"));
|
||||
assertEquals(0, states.saveCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsEverySpeciesWithCurrentProgressOrUnlockedState() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
InMemoryStateStore states = new InMemoryStateStore();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withProgress(TreeSpecies.OAK, 12)
|
||||
.withUnlocked(TreeSpecies.BIRCH, true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(
|
||||
states, ignored -> { }, ignored -> "message", ignored -> 100);
|
||||
|
||||
handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"unlocked"});
|
||||
|
||||
verify(player).sendMessage(contains("Oak: locked (12/100)"));
|
||||
verify(player).sendMessage(contains("Birch: unlocked"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("Mushroom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheExactInventoryShortageForUndo() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.hasPermission("treefeller.undo")).thenReturn(true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(
|
||||
new InMemoryStateStore(),
|
||||
ignored -> { },
|
||||
ignored -> "message",
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.missing(Map.of(Material.OAK_LOG, 3)));
|
||||
|
||||
handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"undo"});
|
||||
|
||||
verify(player).sendMessage(contains("Oak Log x3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesOnlyPlayerCommandSyntaxByArgumentPosition() {
|
||||
TreeFellerCommand handler = new TreeFellerCommand(new InMemoryStateStore(), ignored -> { });
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertEquals(List.of("enabled"), handler.onTabComplete(
|
||||
mock(Player.class), command, "treefeller", new String[] {"e"}));
|
||||
assertEquals(List.of("off", "on"), handler.onTabComplete(
|
||||
mock(Player.class), command, "treefeller", new String[] {"enabled", ""}));
|
||||
assertEquals(List.of(), handler.onTabComplete(
|
||||
mock(Player.class), command, "treefeller", new String[] {"admin", ""}));
|
||||
}
|
||||
|
||||
private static final class InMemoryStateStore implements PlayerStateStore {
|
||||
private PlayerTreeFellerState state;
|
||||
private int saveCount;
|
||||
|
||||
@Override
|
||||
public Optional<PlayerTreeFellerState> load(UUID playerId) {
|
||||
return Optional.ofNullable(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState changed) {
|
||||
state = changed;
|
||||
saveCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeFellingListenerTest {
|
||||
@Test
|
||||
void startsOnlyForAnEnabledUnlockedNonSneakingSurvivalAxeUser() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
Block block = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.COPPER_AXE));
|
||||
TreeStructure tree = new TreeStructure(
|
||||
TreeSpecies.OAK, List.of(new BlockPoint(0, 0, 0), new BlockPoint(0, 1, 0)));
|
||||
PlayerTreeFellerState eligible = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withUnlocked(TreeSpecies.OAK, true);
|
||||
PlayerStateStore states = mock(PlayerStateStore.class);
|
||||
when(states.load(playerId)).thenReturn(Optional.of(eligible));
|
||||
TreeFellingStarter starter = mock(TreeFellingStarter.class);
|
||||
TreeFellingListener listener = new TreeFellingListener(
|
||||
ignored -> Optional.of(tree), states, new AutomaticBreakRegistry(), starter, () -> 2);
|
||||
|
||||
listener.onBlockBreak(new BlockBreakEvent(block, player));
|
||||
when(player.isSneaking()).thenReturn(true);
|
||||
listener.onBlockBreak(new BlockBreakEvent(block, player));
|
||||
|
||||
verify(starter).start(player, block, tree, 2);
|
||||
verify(starter, org.mockito.Mockito.times(1)).start(any(), any(), any(), eq(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNonAxeCanEarnProgressButNeverStartsAutomaticFelling() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.DIAMOND_PICKAXE));
|
||||
PlayerStateStore states = mock(PlayerStateStore.class);
|
||||
when(states.load(playerId)).thenReturn(Optional.of(
|
||||
PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withUnlocked(TreeSpecies.OAK, true)));
|
||||
TreeFellingStarter starter = mock(TreeFellingStarter.class);
|
||||
TreeFellingListener listener = new TreeFellingListener(
|
||||
ignored -> Optional.of(new TreeStructure(
|
||||
TreeSpecies.OAK, List.of(new BlockPoint(0, 0, 0)))),
|
||||
states,
|
||||
new AutomaticBreakRegistry(),
|
||||
starter,
|
||||
() -> 2);
|
||||
|
||||
listener.onBlockBreak(new BlockBreakEvent(mock(Block.class), player));
|
||||
|
||||
verify(starter, never()).start(any(), any(), any(), any(Integer.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void administrativeLockSuppressesFellingWithoutChangingTheOrdinaryBreak() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
|
||||
PlayerTreeFellerState locked = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withUnlocked(TreeSpecies.OAK, true)
|
||||
.withLocked(true);
|
||||
PlayerStateStore states = mock(PlayerStateStore.class);
|
||||
when(states.load(playerId)).thenReturn(Optional.of(locked));
|
||||
TreeFellingStarter starter = mock(TreeFellingStarter.class);
|
||||
TreeFellingListener listener = new TreeFellingListener(
|
||||
ignored -> Optional.of(new TreeStructure(
|
||||
TreeSpecies.OAK, List.of(new BlockPoint(0, 0, 0)))),
|
||||
states,
|
||||
new AutomaticBreakRegistry(),
|
||||
starter,
|
||||
() -> 2,
|
||||
target -> target.sendMessage("administratively locked"));
|
||||
Block block = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
listener.onBlockBreak(new BlockBreakEvent(block, player));
|
||||
|
||||
verify(starter, never()).start(any(), any(), any(), any(Integer.class));
|
||||
verify(player).sendMessage(contains("administratively locked"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeProgressListenerTest {
|
||||
@Test
|
||||
void recordsNaturalTrunkMaterialAfterTheTreeStructureIsNoLongerIntact() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
Block block = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getType()).thenReturn(Material.OAK_LOG);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(block.getX()).thenReturn(1);
|
||||
when(block.getY()).thenReturn(64);
|
||||
when(block.getZ()).thenReturn(2);
|
||||
InMemoryStateStore states = new InMemoryStateStore();
|
||||
AutomaticBreakRegistry automaticBreaks = new AutomaticBreakRegistry();
|
||||
TreeProgressListener listener = new TreeProgressListener(
|
||||
ignored -> Optional.empty(),
|
||||
states,
|
||||
ignored -> 100,
|
||||
automaticBreaks,
|
||||
(ignoredPlayer, ignoredUpdate) -> { },
|
||||
ignored -> { });
|
||||
BlockBreakEvent event = new BlockBreakEvent(block, player);
|
||||
|
||||
automaticBreaks.mark(block);
|
||||
listener.onBlockBreak(event);
|
||||
automaticBreaks.unmark(block);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR));
|
||||
listener.onBlockBreak(event);
|
||||
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.DIAMOND_PICKAXE));
|
||||
listener.onBlockBreak(event);
|
||||
|
||||
assertEquals(2, states.state.progress(TreeSpecies.OAK));
|
||||
}
|
||||
|
||||
private static final class InMemoryStateStore implements PlayerStateStore {
|
||||
private PlayerTreeFellerState state;
|
||||
|
||||
@Override
|
||||
public Optional<PlayerTreeFellerState> load(UUID playerId) {
|
||||
return Optional.ofNullable(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState changed) {
|
||||
state = changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
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.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeProgressTrackerTest {
|
||||
@Test
|
||||
void unlocksExactlyWhenTheIncrementedProgressReachesTheThreshold() {
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
|
||||
.withProgress(TreeSpecies.OAK, 99);
|
||||
|
||||
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.OAK, 100);
|
||||
|
||||
assertEquals(100, update.state().progress(TreeSpecies.OAK));
|
||||
assertTrue(update.state().isUnlocked(TreeSpecies.OAK));
|
||||
assertTrue(update.newlyUnlocked());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesALoweredThresholdOnlyOnTheNextQualifyingBlock() {
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
|
||||
.withEnabled(false)
|
||||
.withLocked(true)
|
||||
.withProgress(TreeSpecies.SPRUCE, 80);
|
||||
|
||||
assertFalse(state.isUnlocked(TreeSpecies.SPRUCE));
|
||||
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.SPRUCE, 50);
|
||||
|
||||
assertEquals(81, update.state().progress(TreeSpecies.SPRUCE));
|
||||
assertTrue(update.state().isUnlocked(TreeSpecies.SPRUCE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverRevokesOrReannouncesAnExistingUnlock() {
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
|
||||
.withProgress(TreeSpecies.BIRCH, 100)
|
||||
.withUnlocked(TreeSpecies.BIRCH, true);
|
||||
|
||||
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.BIRCH, 500);
|
||||
|
||||
assertTrue(update.state().isUnlocked(TreeSpecies.BIRCH));
|
||||
assertFalse(update.newlyUnlocked());
|
||||
assertEquals(101, update.state().progress(TreeSpecies.BIRCH));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
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.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeStructureScannerTest {
|
||||
@Test
|
||||
void recognizesAConnectedLeafBearingTreeWithoutFollowingWoodBelowTheChop() {
|
||||
TestBlocks blocks = new TestBlocks();
|
||||
blocks.put(0, -1, 0, Material.OAK_LOG);
|
||||
blocks.put(0, 0, 0, Material.OAK_LOG);
|
||||
blocks.put(0, 1, 0, Material.OAK_LOG);
|
||||
blocks.put(1, 2, 0, Material.OAK_LOG);
|
||||
blocks.put(1, 2, 1, Material.OAK_LEAVES);
|
||||
TreeStructureScanner scanner = new TreeStructureScanner(100, 16);
|
||||
|
||||
TreeStructure tree = scanner.scan(blocks, new BlockPoint(0, 0, 0)).orElseThrow();
|
||||
|
||||
assertEquals(TreeSpecies.OAK, tree.species());
|
||||
assertEquals(3, tree.trunkBlocks().size());
|
||||
assertFalse(tree.trunkBlocks().contains(new BlockPoint(0, -1, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAConnectedLogStructureWithoutSpeciesFoliage() {
|
||||
TestBlocks blocks = new TestBlocks();
|
||||
blocks.put(0, 0, 0, Material.SPRUCE_LOG);
|
||||
blocks.put(0, 1, 0, Material.SPRUCE_LOG);
|
||||
|
||||
assertTrue(new TreeStructureScanner(100, 16)
|
||||
.scan(blocks, new BlockPoint(0, 0, 0))
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesGiantMushroomsByTheirCaps() {
|
||||
TestBlocks blocks = new TestBlocks();
|
||||
blocks.put(0, 0, 0, Material.MUSHROOM_STEM);
|
||||
blocks.put(0, 1, 0, Material.MUSHROOM_STEM);
|
||||
blocks.put(1, 1, 0, Material.RED_MUSHROOM_BLOCK);
|
||||
|
||||
TreeStructure tree = new TreeStructureScanner(100, 16)
|
||||
.scan(blocks, new BlockPoint(0, 0, 0))
|
||||
.orElseThrow();
|
||||
|
||||
assertEquals(TreeSpecies.RED_MUSHROOM, tree.species());
|
||||
}
|
||||
|
||||
private static final class TestBlocks implements BlockAccess {
|
||||
private final Map<BlockPoint, Material> materials = new HashMap<>();
|
||||
|
||||
void put(int x, int y, int z, Material material) {
|
||||
materials.put(new BlockPoint(x, y, z), material);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Material materialAt(BlockPoint point) {
|
||||
return materials.getOrDefault(point, Material.AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeTaxonomyTest {
|
||||
@Test
|
||||
void mapsEverySupportedNaturalTrunkFamily() {
|
||||
Map<Material, TreeSpecies> expected = Map.ofEntries(
|
||||
Map.entry(Material.OAK_LOG, TreeSpecies.OAK),
|
||||
Map.entry(Material.SPRUCE_LOG, TreeSpecies.SPRUCE),
|
||||
Map.entry(Material.BIRCH_LOG, TreeSpecies.BIRCH),
|
||||
Map.entry(Material.JUNGLE_LOG, TreeSpecies.JUNGLE),
|
||||
Map.entry(Material.ACACIA_LOG, TreeSpecies.ACACIA),
|
||||
Map.entry(Material.DARK_OAK_LOG, TreeSpecies.DARK_OAK),
|
||||
Map.entry(Material.MANGROVE_LOG, TreeSpecies.MANGROVE),
|
||||
Map.entry(Material.CHERRY_LOG, TreeSpecies.CHERRY),
|
||||
Map.entry(Material.PALE_OAK_LOG, TreeSpecies.PALE_OAK),
|
||||
Map.entry(Material.CRIMSON_STEM, TreeSpecies.CRIMSON),
|
||||
Map.entry(Material.WARPED_STEM, TreeSpecies.WARPED));
|
||||
|
||||
expected.forEach((material, species) ->
|
||||
assertEquals(species, TreeTaxonomy.directSpecies(material).orElseThrow()));
|
||||
assertTrue(TreeTaxonomy.directSpecies(Material.BAMBOO).isEmpty());
|
||||
assertTrue(TreeTaxonomy.directSpecies(Material.OAK_WOOD).isEmpty());
|
||||
assertTrue(TreeTaxonomy.directSpecies(Material.STRIPPED_OAK_LOG).isEmpty());
|
||||
assertTrue(TreeTaxonomy.directSpecies(Material.CRIMSON_HYPHAE).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeToolsTest {
|
||||
@Test
|
||||
void acceptsAxesButNotPickaxesOrOtherItems() {
|
||||
assertTrue(TreeTools.isAxe(Material.WOODEN_AXE));
|
||||
assertTrue(TreeTools.isAxe(Material.GOLDEN_AXE));
|
||||
assertTrue(TreeTools.isAxe(Material.COPPER_AXE));
|
||||
assertTrue(TreeTools.isAxe(Material.NETHERITE_AXE));
|
||||
assertFalse(TreeTools.isAxe(Material.DIAMOND_PICKAXE));
|
||||
assertFalse(TreeTools.isAxe(Material.AIR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
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.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeUndoServiceTest {
|
||||
private static final Instant FELLED_AT = Instant.parse("2026-08-11T20:00:00Z");
|
||||
|
||||
@Test
|
||||
void reportsEveryMissingInventoryMaterialWithoutChangingTheWorld() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(
|
||||
fixture.snapshot(0, Material.OAK_LOG),
|
||||
fixture.snapshot(1, Material.OAK_LOG),
|
||||
fixture.snapshot(2, Material.BIRCH_LOG)));
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.MISSING_MATERIALS, result.status());
|
||||
assertEquals(1, result.missingMaterials().get(Material.OAK_LOG));
|
||||
assertEquals(1, result.missingMaterials().get(Material.BIRCH_LOG));
|
||||
verify(fixture.block, never()).setBlockData(any(BlockData.class), anyBoolean());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallyConsumesMaterialsRestoresBlockDataAndConsumesTheRecord() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.SUCCESS, result.status());
|
||||
verify(fixture.inventory).setItem(0, null);
|
||||
verify(fixture.block).setBlockData(fixture.restoredData, false);
|
||||
assertTrue(fixture.store.get(fixture.playerId).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesTheWholeUndoWhenAnyRestorationPositionIsOccupied() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
when(fixture.block.isEmpty()).thenReturn(false);
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.BLOCKED, result.status());
|
||||
verify(fixture.inventory, never()).setItem(anyInt(), nullable(ItemStack.class));
|
||||
verify(fixture.block, never()).setBlockData(any(BlockData.class), anyBoolean());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiresTheRecordAfterTheConfiguredWindow() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(361));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.EXPIRED, result.status());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isEmpty());
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final UUID playerId = UUID.randomUUID();
|
||||
private final Clock fellingClock = Clock.fixed(FELLED_AT, ZoneOffset.UTC);
|
||||
private final Clock undoClock;
|
||||
private final LastFellingStore store = new LastFellingStore(fellingClock);
|
||||
private final Player player = mock(Player.class);
|
||||
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
private final World world = mock(World.class);
|
||||
private final Block block = mock(Block.class);
|
||||
private final BlockData emptyData = mock(BlockData.class);
|
||||
private final BlockData restoredData = mock(BlockData.class);
|
||||
|
||||
private Fixture(Instant now) {
|
||||
undoClock = Clock.fixed(now, ZoneOffset.UTC);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(block);
|
||||
when(block.isEmpty()).thenReturn(true);
|
||||
when(block.getBlockData()).thenReturn(emptyData);
|
||||
}
|
||||
|
||||
private void record(List<FelledBlockSnapshot> snapshots) {
|
||||
store.onFelling(player, snapshots);
|
||||
}
|
||||
|
||||
private FelledBlockSnapshot snapshot(int y, Material material) {
|
||||
return new FelledBlockSnapshot(
|
||||
world.getUID(), "world", 0, 64 + y, 0, material, material.name());
|
||||
}
|
||||
|
||||
private TreeUndoService service() {
|
||||
return new TreeUndoService(
|
||||
store,
|
||||
undoClock,
|
||||
ignored -> world,
|
||||
ignored -> restoredData,
|
||||
ignored -> { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TreeUnlockAnnouncementTest {
|
||||
@Test
|
||||
void celebratesANewUnlockAndExplainsSneakingAndUndo() throws Exception {
|
||||
TreeFellerSettings settings = defaults();
|
||||
Player player = mock(Player.class);
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
|
||||
.withUnlocked(TreeSpecies.DARK_OAK, true);
|
||||
TreeUnlockAnnouncement announcement = new TreeUnlockAnnouncement(() -> settings);
|
||||
|
||||
announcement.onProgress(player, new ProgressUpdate(
|
||||
state, TreeSpecies.DARK_OAK, 100, 100, true));
|
||||
|
||||
verify(player).sendTitle(
|
||||
"§aDark Oak unlocked!",
|
||||
"§fYou can now fell this tree type.",
|
||||
10,
|
||||
70,
|
||||
20);
|
||||
verify(player).sendMessage(contains("Sneak"));
|
||||
verify(player).sendMessage(contains("/treefeller undo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAnnounceOrdinaryProgressOrAnExistingUnlock() throws Exception {
|
||||
Player player = mock(Player.class);
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player");
|
||||
TreeUnlockAnnouncement announcement = new TreeUnlockAnnouncement(this::uncheckedDefaults);
|
||||
|
||||
announcement.onProgress(player, new ProgressUpdate(
|
||||
state, TreeSpecies.OAK, 5, 100, false));
|
||||
|
||||
verify(player, never()).sendTitle(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt());
|
||||
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
private TreeFellerSettings uncheckedDefaults() {
|
||||
try {
|
||||
return defaults();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private TreeFellerSettings defaults() throws Exception {
|
||||
try (InputStreamReader reader = new InputStreamReader(
|
||||
getClass().getClassLoader().getResourceAsStream("config.yml"),
|
||||
StandardCharsets.UTF_8)) {
|
||||
return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user