Compare commits
17
Commits
12a8e84535
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b41190a09 | ||
|
|
2a793e8c69 | ||
|
|
3dfc1b71af | ||
|
|
f26b7d0f0d | ||
|
|
e7a5a14529 | ||
|
|
904faf1832 | ||
|
|
2e6fdb4e5b | ||
|
|
5d1577175f | ||
|
|
d57f8cc6dd | ||
|
|
206096efc2 | ||
|
|
2664f7c68b | ||
|
|
8c7c008ca0 | ||
|
|
17f1631b96 | ||
|
|
87452e4b87 | ||
|
|
9600520e14 | ||
|
|
486780c049 | ||
|
|
8067b3ebfb |
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Validate conventional commits
|
||||
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tree-feller-${{ github.sha }}
|
||||
name: purpur-tree-feller-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Node.js
|
||||
@@ -86,8 +86,8 @@ jobs:
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tree-feller-${{ steps.release.outputs.version }}
|
||||
path: build/libs/tree-feller-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-tree-feller-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-tree-feller-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Gitea release and upload plugin
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
run: |
|
||||
api_url="${GITEA_SERVER_URL}/api/v1"
|
||||
jar="build/libs/tree-feller-${VERSION}.jar"
|
||||
jar="build/libs/purpur-tree-feller-${VERSION}.jar"
|
||||
export RELEASE_BODY
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
|
||||
@@ -138,4 +138,4 @@ jobs:
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${jar}" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=tree-feller-${VERSION}.jar"
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-tree-feller-${VERSION}.jar"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.gradle/
|
||||
.docker/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# purpur-tree-feller agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-tree-feller/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-tree-feller/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Purpur Tree Feller
|
||||
|
||||
Tree Feller is a Purpur 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 [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25
|
||||
|
||||
Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFeller`, Java packages/entrypoint, `plugins/TreeFeller/`, commands/permissions and player progression/preferences remain unchanged. Replace the old JAR rather than installing both distributions. Old release tags/assets remain available; new releases no longer support Java 17.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./gradlew clean check jar
|
||||
```
|
||||
|
||||
The plugin JAR is written to `build/libs/purpur-tree-feller-<version>.jar`. `check` includes `nativeTreeTest`, loading real item registries/services from SHA-256-verified Purpur 2618 under `build/tree-runtime/` without starting a listening server. Existing item/felling/undo assertions are retained. `LocalTestHarnessTest` checks parsed Compose settings and runs the actual startup script with isolated command doubles; it does not start Docker.
|
||||
|
||||
## Local Docker test server
|
||||
|
||||
Build the current plugin and start it on an isolated Java 25/Purpur 26.2 build 2618 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 Purpur 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
|
||||
```
|
||||
|
||||
The harness now mounts the `purpur-tree-feller` snapshot JAR at the unchanged `/plugins/TreeFeller.jar` path. A manual startup after migration switches the local test world from Spigot to Purpur; it preserves existing `.docker/minecraft/` state, so back up valuable test worlds before running it. The migration itself does not start or recreate containers.
|
||||
|
||||
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 through the server API 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 Purpur 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use the server'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`.
|
||||
+64
-5
@@ -1,3 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
@@ -9,23 +12,23 @@ version = providers.gradleProperty("releaseVersion")
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
|
||||
maven("https://repo.purpurmc.org/snapshots/")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
|
||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
@@ -35,8 +38,64 @@ dependencies {
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
// Real item/felling/undo regressions require the declared runtime's registries and service providers.
|
||||
val treeRuntime = layout.buildDirectory.dir("tree-runtime")
|
||||
val downloadTreeRuntime = tasks.register("downloadTreeRuntime") {
|
||||
val launcher = treeRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||
"Purpur 2618 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
val prepareTreeRuntime = tasks.register<JavaExec>("prepareTreeRuntime") {
|
||||
dependsOn(downloadTreeRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(treeRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(treeRuntime)
|
||||
outputs.dir(treeRuntime.map { it.dir("versions") })
|
||||
outputs.dir(treeRuntime.map { it.dir("libraries") })
|
||||
}
|
||||
val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
val nativeRuntimeJars = files(fileTree(treeRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareTreeRuntime)
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeTreeTest = tasks.register<Test>("nativeTreeTest") {
|
||||
description = "Runs real item/felling/undo regressions without starting a server"
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(treeRuntime)
|
||||
}
|
||||
tasks.check { dependsOn(nativeTreeTest) }
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
tasks.processResources {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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: PURPUR
|
||||
VERSION: "26.2"
|
||||
PURPUR_BUILD: "2618"
|
||||
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/purpur-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
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Tree Feller Design
|
||||
|
||||
This bundle documents Tree Feller, a Spigot 26.2 plugin through which players earn per-species automatic tree felling, watch trees break progressively, and safely undo their most recent felling.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
# Tree Feller Design Log
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
### 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.
|
||||
- Each species defaults to a 100-block unlock threshold; lowered thresholds are evaluated on the player's next qualifying block, while earned unlocks are permanent unless an administrator resets them.
|
||||
- Supported species cover Spigot 26.2 overworld trees, crimson and warped fungi, and giant red and brown mushrooms; bamboo is excluded.
|
||||
- The prior `../yetanothertreefeller/` search strategy informs detection: connected trunk blocks are searched laterally and upward, never downward, and associated foliage or caps establish that the structure is a tree. The new implementation will use bounded iteration rather than unbounded recursion.
|
||||
- Unlocked trees break from bottom to top, one trunk block every two ticks by default. Leaves and caps are not automatically removed.
|
||||
- Sneaking bypasses automatic felling. Players may also persistently enable or disable it with `/treefeller enabled <on|off>`.
|
||||
- The player's latest felling may be undone for six minutes by default. Undo preflights the complete operation, reclaims all required replacement materials from inventory, and reports shortages without making partial changes.
|
||||
- Unlock progress is shown through `/treefeller unlocked` and a temporary boss bar. Unlocks produce a title and a chat explanation of sneaking and undo.
|
||||
- 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`.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Tree Feller User Stories
|
||||
|
||||
1. [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
2. [US-002: View tree progress](us-002-view-tree-progress.md)
|
||||
3. [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||
4. [US-004: Control personal tree felling](us-004-control-personal-tree-felling.md)
|
||||
5. [US-005: Undo the last felled tree](us-005-undo-the-last-felled-tree.md)
|
||||
6. [US-006: Announce tree unlocks](us-006-announce-tree-unlocks.md)
|
||||
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)
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-001: Earn tree-type unlocks
|
||||
|
||||
As a **survival player**, I want to unlock Tree Feller by practicing with each tree species so that automatic felling feels earned and species-specific.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: View tree progress](us-002-view-tree-progress.md)
|
||||
- [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||
- [US-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-002: View tree progress
|
||||
|
||||
As a **player**, I want clear unlock and progress information so that I know which trees I can fell and what to work on next.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
- [US-006: Announce tree unlocks](us-006-announce-tree-unlocks.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-003: Fell unlocked trees
|
||||
|
||||
As a **player with an unlocked species**, I want its trees to break progressively from the point I chop so that felling is useful, understandable, and visually satisfying.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
- [US-004: Control personal tree felling](us-004-control-personal-tree-felling.md)
|
||||
- [US-005: Undo the last felled tree](us-005-undo-the-last-felled-tree.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Control personal tree felling"
|
||||
description: Let players persistently enable or disable their own automatic tree felling.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-004: Control personal tree felling
|
||||
|
||||
As a **player**, I want to turn automatic felling on or off independently of my unlocks so that I remain in control of how I mine.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: View tree progress](us-002-view-tree-progress.md)
|
||||
- [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||
- [US-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-005: Undo the last felled tree
|
||||
|
||||
As a **player**, I want to undo my latest automatically felled tree so that I can recover from an accidental chop without duplicating its materials.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||
- [US-004: Control personal tree felling](us-004-control-personal-tree-felling.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-006: Announce tree unlocks
|
||||
|
||||
As a **player**, I want visible and actionable feedback when I unlock a species so that I notice the achievement and understand Tree Feller's safety controls.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
- [US-002: View tree progress](us-002-view-tree-progress.md)
|
||||
- [US-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-007: Administer Tree Feller"
|
||||
description: Give administrators separate, structured commands for player access, progress, and species thresholds.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-007: Administer Tree Feller
|
||||
|
||||
As a **server administrator**, I want a dedicated administrative command tree so that I can manage Tree Feller without granting ordinary players administrative authority.
|
||||
|
||||
## 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
- [US-004: Control personal tree felling](us-004-control-personal-tree-felling.md)
|
||||
- [US-006: Announce tree unlocks](us-006-announce-tree-unlocks.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-tree-feller.md)
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-008: Configure and persist Tree Feller"
|
||||
description: Provide validated configuration and durable player state for predictable tree-felling behavior.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-008: Configure and persist Tree Feller
|
||||
|
||||
As a **server operator**, I want Tree Feller's behavior and player progression stored safely so that upgrades and restarts do not cause surprises or lost progress.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Configuration defines supported species and their thresholds, each defaulting to 100 qualifying blocks.
|
||||
- [x] Configuration defines an animation delay defaulting to two server ticks per felled block.
|
||||
- [x] Configuration defines a progress boss-bar timeout defaulting to five seconds and an undo window defaulting to six minutes.
|
||||
- [x] Configuration includes bounded tree-search limits, boss-bar presentation, title timing, and all player-facing messages.
|
||||
- [x] Numeric settings reject zero, negative, overflowing, or operationally unsafe values using documented bounds.
|
||||
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message identifying the setting.
|
||||
- [x] Runtime threshold changes made through `/treefelleradmin` are persisted before becoming active and survive restart.
|
||||
- [x] A failed runtime configuration write leaves the active threshold unchanged.
|
||||
- [x] UUID-keyed player state persists latest known name, enabled preference, administrative lock, per-species progress, and earned unlocks.
|
||||
- [x] Player identity resolution safely retains previously known names while UUID remains authoritative.
|
||||
- [x] Player state is written atomically where supported so that a failed write does not replace valid state with a partial document.
|
||||
- [x] Corrupt or invalid player records are handled defensively and cannot silently grant unlocks or administrative privileges.
|
||||
- [x] Progress that exceeds a currently configured threshold is retained, and counters cannot overflow.
|
||||
- [x] Unknown forward-compatible configuration and player-state fields are preserved where practical.
|
||||
- [x] Undo records are runtime safety records and do not survive a server restart unless a later approved design explicitly adds durable undo.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Earn tree-type unlocks](us-001-earn-tree-type-unlocks.md)
|
||||
- [US-002: View tree progress](us-002-view-tree-progress.md)
|
||||
- [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-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
- [US-009: Build and release Tree Feller](us-009-build-and-release-tree-feller.md)
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# US-009: Build and release Tree Feller
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on `spigot-getgud` so that tested, correctly versioned Tree Feller artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [ ] 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] 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.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-007: Administer Tree Feller](us-007-administer-tree-feller.md)
|
||||
- [US-008: Configure and persist Tree Feller](us-008-configure-and-persist-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 Purpur 26.2 build 2618..."
|
||||
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 Purpur 26.2 build 2618 (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
|
||||
+1
-1
@@ -1 +1 @@
|
||||
rootProject.name = "tree-feller"
|
||||
rootProject.name = "purpur-tree-feller"
|
||||
|
||||
@@ -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,155 @@
|
||||
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 {
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@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,61 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() {}
|
||||
private static boolean ready;
|
||||
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 {
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@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,77 @@
|
||||
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 {
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@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,140 @@
|
||||
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 {
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
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,114 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/** Executes the real startup script with isolated CLI doubles; never starts Docker. */
|
||||
class LocalTestHarnessTest {
|
||||
@TempDir Path temporary;
|
||||
|
||||
@Test
|
||||
void composeUsesPinnedPurpurAndCurrentJarWithoutChangingIsolationOrState() throws Exception {
|
||||
Map<?, ?> compose = new Yaml().load(Files.readString(Path.of("compose.test.yml")));
|
||||
Map<?, ?> service = (Map<?, ?>) ((Map<?, ?>) compose.get("services")).get("minecraft");
|
||||
Map<?, ?> environment = (Map<?, ?>) service.get("environment");
|
||||
assertEquals("PURPUR", environment.get("TYPE"));
|
||||
assertEquals("2618", environment.get("PURPUR_BUILD"));
|
||||
assertEquals("26.2", environment.get("VERSION"));
|
||||
assertEquals("itzg/minecraft-server:java25", service.get("image"));
|
||||
assertEquals("tree-feller-test", compose.get("name"));
|
||||
assertEquals("tree-feller-test", service.get("container_name"));
|
||||
assertEquals(List.of("127.0.0.1:25565:25565"), service.get("ports"));
|
||||
assertEquals(List.of("./.docker/minecraft:/data",
|
||||
"./build/libs/purpur-tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro",
|
||||
"./docker/test-ops.json:/config/tree-feller-ops.json:ro"), service.get("volumes"));
|
||||
assertEquals("false", environment.get("ONLINE_MODE"));
|
||||
assertEquals("true", environment.get("OVERRIDE_OPS"));
|
||||
assertEquals("/config/tree-feller-ops.json", environment.get("OPS_FILE"));
|
||||
assertEquals("true", environment.get("ENABLE_RCON"));
|
||||
assertEquals("${TREE_FELLER_RCON_PASSWORD:-tree-feller-local-test}", environment.get("RCON_PASSWORD"));
|
||||
List<?> ops = new Yaml().load(Files.readString(Path.of("docker/test-ops.json")));
|
||||
assertEquals(1, ops.size());
|
||||
Map<?, ?> operator = (Map<?, ?>) ops.getFirst();
|
||||
assertEquals("WindMagi", operator.get("name"));
|
||||
assertEquals("6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e", operator.get("uuid"));
|
||||
assertEquals(4, operator.get("level"));
|
||||
assertTrue(Files.readString(Path.of(".gitignore")).contains(".docker/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupVerifiesBuildBeforeRecreateAndRetainsWorldState() throws Exception {
|
||||
Result result = runStartup(false);
|
||||
assertEquals(0, result.exitCode(), result.output());
|
||||
assertTrue(result.output().contains("Purpur 26.2 build 2618"));
|
||||
assertTrue(result.output().contains("Tree Feller test server is ready"));
|
||||
assertTrue(result.calls().indexOf("gradle clean check jar") < result.calls().indexOf("up -d --force-recreate"));
|
||||
assertTrue(result.calls().contains("rcon-cli plugins"));
|
||||
assertTrue(result.calls().contains("cat /data/ops.json"));
|
||||
assertFalse(result.calls().contains("down"));
|
||||
assertEquals("existing world", Files.readString(temporary.resolve(".docker/minecraft/sentinel")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedBuildNeverStartsAContainer() throws Exception {
|
||||
Result result = runStartup(true);
|
||||
assertNotEquals(0, result.exitCode());
|
||||
assertTrue(result.calls().contains("gradle clean check jar"));
|
||||
assertFalse(result.calls().contains("up -d"));
|
||||
assertFalse(result.calls().contains("rcon-cli"));
|
||||
}
|
||||
|
||||
private Result runStartup(boolean failBuild) throws Exception {
|
||||
Files.createDirectories(temporary.resolve("scripts"));
|
||||
Files.createDirectories(temporary.resolve("bin"));
|
||||
Files.createDirectories(temporary.resolve(".docker/minecraft"));
|
||||
Files.writeString(temporary.resolve(".docker/minecraft/sentinel"), "existing world");
|
||||
Files.copy(Path.of("scripts/start-test-server.sh"), temporary.resolve("scripts/start-test-server.sh"));
|
||||
Files.copy(Path.of("docker/test-ops.json"), temporary.resolve("ops.json"));
|
||||
executable(temporary.resolve("gradlew"), "#!/bin/sh\nprintf 'gradle %s\\n' \"$*\" >> \"$TEST_CALLS\"\nexit " + (failBuild ? "1" : "0") + "\n");
|
||||
executable(temporary.resolve("bin/docker"), """
|
||||
#!/bin/sh
|
||||
printf 'docker %s\n' "$*" >> "$TEST_CALLS"
|
||||
case "$*" in
|
||||
info|'compose version') exit 0 ;;
|
||||
*'up -d --force-recreate') exit 0 ;;
|
||||
*'ps -q minecraft') echo fake-container ;;
|
||||
*'{{if .State.Health}}'*) echo healthy ;;
|
||||
*'{{.State.Status}}'*) echo running ;;
|
||||
*'rcon-cli plugins') echo TreeFeller ;;
|
||||
*'cat /data/ops.json') cat "$TEST_OPS" ;;
|
||||
*) echo 'Unexpected Docker command' >&2; exit 42 ;;
|
||||
esac
|
||||
""");
|
||||
Path output = temporary.resolve("output");
|
||||
Path calls = temporary.resolve("calls");
|
||||
var builder = new ProcessBuilder("bash", temporary.resolve("scripts/start-test-server.sh").toString());
|
||||
builder.environment().put("PATH", temporary.resolve("bin") + ":" + System.getenv("PATH"));
|
||||
builder.environment().put("TEST_CALLS", calls.toString());
|
||||
builder.environment().put("TEST_OPS", temporary.resolve("ops.json").toString());
|
||||
builder.environment().put("TREE_FELLER_START_TIMEOUT", "5");
|
||||
builder.redirectErrorStream(true).redirectOutput(output.toFile());
|
||||
Process process = builder.start();
|
||||
try {
|
||||
assertTrue(process.waitFor(15, TimeUnit.SECONDS), "Isolated startup script timed out");
|
||||
return new Result(process.exitValue(), Files.readString(output), Files.readString(calls));
|
||||
} finally {
|
||||
if (process.isAlive()) { process.destroyForcibly(); }
|
||||
}
|
||||
}
|
||||
|
||||
private static void executable(Path path, String content) throws Exception {
|
||||
Files.writeString(path, content);
|
||||
assertTrue(path.toFile().setExecutable(true));
|
||||
}
|
||||
|
||||
private record Result(int exitCode, String output, String calls) { }
|
||||
}
|
||||
@@ -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,53 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
@Test
|
||||
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-tree-feller-" + version + ".jar", artifact.getFileName().toString());
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("TreeFeller", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("treefeller.admin"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/treefeller/TreeFellerPlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
assertTrue(content.contains("purpur-tree-feller-"), workflow);
|
||||
assertFalse(content.contains("build/libs/tree-feller-") || content.contains("name: tree-feller-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,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