Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a5a14529 | ||
|
|
904faf1832 | ||
|
|
2e6fdb4e5b |
@@ -1,4 +1,5 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
|
.docker/
|
||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -17,6 +17,40 @@ The approved behavior and implementation record are in the [OKF design bundle](d
|
|||||||
|
|
||||||
The plugin JAR is written to `build/libs/`.
|
The plugin JAR is written to `build/libs/`.
|
||||||
|
|
||||||
|
## Local Docker test server
|
||||||
|
|
||||||
|
Build the current plugin and start it on an isolated Spigot 26.2 server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/start-test-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect to `localhost:25565` as `WindMagi`, which the harness configures as a level-4 operator using its deterministic offline-mode UUID. The committed `docker/test-ops.json` fixture replaces local operator state on every startup so the offline UUID remains deterministic. The server uses offline mode for local convenience and binds only to the loopback interface.
|
||||||
|
|
||||||
|
The first startup may take several minutes while the image downloads and Spigot is prepared. Override the 15-minute wait or local RCON password when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TREE_FELLER_START_TIMEOUT=1200 \
|
||||||
|
TREE_FELLER_RCON_PASSWORD='local-secret' \
|
||||||
|
./scripts/start-test-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Common lifecycle commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow server logs
|
||||||
|
docker compose -f compose.test.yml logs -f minecraft
|
||||||
|
|
||||||
|
# Stop while preserving the world and server configuration
|
||||||
|
docker compose -f compose.test.yml down
|
||||||
|
|
||||||
|
# Stop and permanently reset all generated local test state
|
||||||
|
docker compose -f compose.test.yml down
|
||||||
|
rm -rf .docker/minecraft
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run the start script after code changes to rebuild the JAR and recreate the test container. Generated server state remains under `.docker/minecraft/` and is ignored by Git.
|
||||||
|
|
||||||
## Player commands
|
## Player commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: tree-feller-test
|
||||||
|
|
||||||
|
services:
|
||||||
|
minecraft:
|
||||||
|
image: itzg/minecraft-server:java25
|
||||||
|
container_name: tree-feller-test
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:25565:25565"
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
TYPE: SPIGOT
|
||||||
|
VERSION: "26.2"
|
||||||
|
MEMORY: 2G
|
||||||
|
ONLINE_MODE: "false"
|
||||||
|
OPS_FILE: /config/tree-feller-ops.json
|
||||||
|
OVERRIDE_OPS: "true"
|
||||||
|
ENABLE_RCON: "true"
|
||||||
|
RCON_PASSWORD: "${TREE_FELLER_RCON_PASSWORD:-tree-feller-local-test}"
|
||||||
|
VIEW_DISTANCE: "6"
|
||||||
|
SIMULATION_DISTANCE: "6"
|
||||||
|
SPAWN_PROTECTION: "0"
|
||||||
|
volumes:
|
||||||
|
- ./.docker/minecraft:/data
|
||||||
|
- ./build/libs/tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro
|
||||||
|
- ./docker/test-ops.json:/config/tree-feller-ops.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc-health"]
|
||||||
|
start_period: 15m
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 90
|
||||||
|
restart: "no"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
## 2026-08-11
|
## 2026-08-11
|
||||||
|
|
||||||
|
### US-001 tool-independent progress corrected
|
||||||
|
|
||||||
|
- Reproduced the progression gap caused by sharing the automatic-felling axe restriction with manual unlock progress.
|
||||||
|
- Qualifying validated Survival tree blocks now add progress when broken with an axe, another tool, or an empty hand; cancelled, Creative, and automatically felled blocks remain excluded.
|
||||||
|
- Automatic felling remains strictly axe-only, including for players who have already unlocked the species.
|
||||||
|
- Verified empty-hand and pickaxe progress, automatic-break suppression, axe-only felling, and the complete build with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
### US-010 offline operator identity corrected
|
||||||
|
|
||||||
|
- Reproduced WindMagi's missing operator privileges and found that name-based image setup stored the online account UUID while offline-mode login assigned deterministic UUID `6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e`.
|
||||||
|
- Replaced name-based setup with a synchronized, committed operator fixture containing the exact offline UUID; synchronization intentionally makes WindMagi the sole operator in this isolated test harness.
|
||||||
|
- Removed the misleading post-start `op WindMagi` check and made startup verify the exact name and UUID in the generated `ops.json`.
|
||||||
|
- Live restart verification passed: Spigot was healthy, RCON reported TreeFeller, `ops.json` contained only WindMagi at level 4 with the offline UUID, and the server remains available at `localhost:25565`.
|
||||||
|
|
||||||
|
### US-010 local Docker test server completed
|
||||||
|
|
||||||
|
- Added a repository-local Docker Compose harness using `itzg/minecraft-server:java25` with Spigot 26.2, persistent ignored state, offline local testing, and loopback-only port binding.
|
||||||
|
- Added one executable script that runs the strict Gradle build, recreates the isolated container with the latest JAR, waits for health, verifies Tree Feller through RCON, and confirms `WindMagi` as an operator.
|
||||||
|
- Documented start, log, stop, persistent-state reset, timeout, and RCON-password controls.
|
||||||
|
- Live verification completed in 23 seconds: the container was healthy, RCON reported `TreeFeller`, `ops.json` contained `WindMagi` at level 4, and port 25565 was bound only to `127.0.0.1`.
|
||||||
|
- The test server remains running at `localhost:25565` for gameplay testing.
|
||||||
|
|
||||||
### US-009 build and release completed
|
### US-009 build and release completed
|
||||||
|
|
||||||
- Added maintainer documentation for requirements, builds, commands, configuration, supported species, persistence, safety, compatibility, and releases.
|
- Added maintainer documentation for requirements, builds, commands, configuration, supported species, persistence, safety, compatibility, and releases.
|
||||||
|
|||||||
@@ -9,3 +9,4 @@
|
|||||||
7. [US-007: Administer Tree Feller](us-007-administer-tree-feller.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)
|
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)
|
9. [US-009: Build and release Tree Feller](us-009-build-and-release-tree-feller.md)
|
||||||
|
10. [US-010: Run a local Docker test server](us-010-run-local-docker-test-server.md)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ As a **survival player**, I want to unlock Tree Feller by practicing with each t
|
|||||||
|
|
||||||
- [x] Progress is tracked independently for oak, spruce, birch, jungle, acacia, dark oak, mangrove, cherry, pale oak, crimson fungi, warped fungi, giant red mushrooms, and giant brown mushrooms as represented by Spigot 26.2 materials.
|
- [x] Progress is tracked independently for oak, spruce, birch, jungle, acacia, dark oak, mangrove, cherry, pale oak, crimson fungi, warped fungi, giant red mushrooms, and giant brown mushrooms as represented by Spigot 26.2 materials.
|
||||||
- [x] Azalea-grown oak logs contribute to oak progress, and bamboo does not contribute to any tree species.
|
- [x] Azalea-grown oak logs contribute to oak progress, and bamboo does not contribute to any tree species.
|
||||||
- [x] 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.
|
- [x] A block contributes progress when a player manually breaks it in Survival mode from a structure that passes Tree Feller's tree validation, regardless of whether the player uses an axe, another tool, or an empty hand.
|
||||||
- [x] Creative-mode breaks, non-axe breaks, cancelled breaks, and blocks removed by automatic felling do not contribute progress.
|
- [x] Creative-mode breaks, cancelled breaks, and blocks removed by automatic felling do not contribute progress.
|
||||||
- [x] Each qualifying manually mined block contributes exactly one point to its species.
|
- [x] Each qualifying manually mined block contributes exactly one point to its species.
|
||||||
- [x] Each species has an independently configurable unlock threshold that defaults to 100 blocks.
|
- [x] Each species has an independently configurable unlock threshold that defaults to 100 blocks.
|
||||||
- [x] Reaching the active threshold permanently unlocks automatic felling for that species.
|
- [x] Reaching the active threshold permanently unlocks automatic felling for that species.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-010: Run a local Docker test server"
|
||||||
|
description: Provide a repeatable Docker Compose harness that builds and loads Tree Feller on an isolated Spigot 26.2 server.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-010: Run a local Docker test server
|
||||||
|
|
||||||
|
As a **plugin developer**, I want one command to run the current Tree Feller build on a local containerized server so that I can perform repeatable integration and gameplay testing.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] One repository script builds and verifies the plugin before starting an isolated Spigot 26.2 server through Docker Compose.
|
||||||
|
- [x] The current Tree Feller JAR is mounted into the server automatically whenever the container is recreated.
|
||||||
|
- [x] `WindMagi` is configured and verified at operator level 4 using the deterministic offline-mode UUID `6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e`.
|
||||||
|
- [x] The Minecraft port binds only to `127.0.0.1:25565` by default.
|
||||||
|
- [x] Startup waits for server health and verifies Tree Feller through RCON.
|
||||||
|
- [x] Generated worlds, logs, configuration, and downloaded server artifacts are stored under `.docker/minecraft/` and excluded from Git.
|
||||||
|
- [x] Stopping Compose preserves generated test state unless the operator explicitly deletes it.
|
||||||
|
- [x] Existing unrelated containers are not modified.
|
||||||
|
- [x] Maintainer documentation explains start, logs, stop, reset, timeout, and RCON-password controls.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This harness is for local development only. It does not define production deployment, publish an image, expose Minecraft or RCON publicly, or persist its runtime state in Git.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-003: Fell unlocked trees](us-003-fell-unlocked-trees.md)
|
||||||
|
- [US-005: Undo the last felled tree](us-005-undo-the-last-felled-tree.md)
|
||||||
|
- [US-009: Build and release Tree Feller](us-009-build-and-release-tree-feller.md)
|
||||||
|
- [User-story catalog](index.md)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e",
|
||||||
|
"name": "WindMagi",
|
||||||
|
"level": 4,
|
||||||
|
"bypassesPlayerLimit": false
|
||||||
|
}
|
||||||
|
]
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
compose_file="$project_root/compose.test.yml"
|
||||||
|
wait_seconds="${TREE_FELLER_START_TIMEOUT:-900}"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Required command not found: docker" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker is not running or is not accessible." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "Docker Compose v2 is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$project_root"
|
||||||
|
./gradlew clean check jar
|
||||||
|
mkdir -p .docker/minecraft
|
||||||
|
|
||||||
|
# Recreate the container so the image installs the newly built read-only plugin JAR.
|
||||||
|
docker compose -f "$compose_file" up -d --force-recreate
|
||||||
|
container_id="$(docker compose -f "$compose_file" ps -q minecraft)"
|
||||||
|
if [[ -z "$container_id" ]]; then
|
||||||
|
echo "The Tree Feller test container was not created." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting up to ${wait_seconds}s for Spigot 26.2..."
|
||||||
|
deadline=$((SECONDS + wait_seconds))
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
container_state="$(docker inspect --format '{{.State.Status}}' "$container_id")"
|
||||||
|
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
||||||
|
if [[ "$health_state" == "healthy" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "$container_state" == "exited" || "$container_state" == "dead" ]]; then
|
||||||
|
docker compose -f "$compose_file" logs --tail=150 minecraft >&2
|
||||||
|
echo "The Minecraft server exited during startup." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
||||||
|
if [[ "$health_state" != "healthy" ]]; then
|
||||||
|
docker compose -f "$compose_file" logs --tail=150 minecraft >&2
|
||||||
|
echo "Timed out waiting for Spigot 26.2 (status: $health_state)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
plugins="$(docker compose -f "$compose_file" exec -T minecraft rcon-cli plugins)"
|
||||||
|
if [[ "$plugins" != *"TreeFeller"* ]]; then
|
||||||
|
echo "$plugins" >&2
|
||||||
|
echo "TreeFeller was not reported by the running server." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Offline mode assigns a deterministic UUID that differs from the account's online UUID.
|
||||||
|
# Verify the exact identity instead of issuing `op WindMagi`, which can resolve online identity.
|
||||||
|
offline_uuid="6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e"
|
||||||
|
ops_json="$(docker compose -f "$compose_file" exec -T minecraft sh -lc 'cat /data/ops.json')"
|
||||||
|
if [[ "$ops_json" != *'"name": "WindMagi"'* || "$ops_json" != *"\"uuid\": \"${offline_uuid}\""* ]]; then
|
||||||
|
echo "$ops_json" >&2
|
||||||
|
echo "WindMagi's offline-mode UUID was not installed as an operator." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'MESSAGE'
|
||||||
|
Tree Feller test server is ready at localhost:25565.
|
||||||
|
Operator: WindMagi
|
||||||
|
View logs: docker compose -f compose.test.yml logs -f minecraft
|
||||||
|
Stop: docker compose -f compose.test.yml down
|
||||||
|
Reset: docker compose -f compose.test.yml down && rm -rf .docker/minecraft
|
||||||
|
MESSAGE
|
||||||
@@ -40,7 +40,6 @@ public final class TreeProgressListener implements Listener {
|
|||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
if (event.isCancelled()
|
if (event.isCancelled()
|
||||||
|| player.getGameMode() != GameMode.SURVIVAL
|
|| player.getGameMode() != GameMode.SURVIVAL
|
||||||
|| !TreeTools.isAxe(player.getInventory().getItemInMainHand().getType())
|
|
||||||
|| automaticBreaks.isMarked(event.getBlock())) {
|
|| automaticBreaks.isMarked(event.getBlock())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,34 @@ class TreeFellingListenerTest {
|
|||||||
verify(starter, org.mockito.Mockito.times(1)).start(any(), any(), any(), eq(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
|
@Test
|
||||||
void administrativeLockSuppressesFellingWithoutChangingTheOrdinaryBreak() {
|
void administrativeLockSuppressesFellingWithoutChangingTheOrdinaryBreak() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
class TreeProgressListenerTest {
|
class TreeProgressListenerTest {
|
||||||
@Test
|
@Test
|
||||||
void recordsOnlyManualSurvivalAxeBreaksFromValidatedTrees() throws Exception {
|
void recordsManualSurvivalBreaksWithAnEmptyHandOrAnyToolFromValidatedTrees() throws Exception {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
Player player = mock(Player.class);
|
Player player = mock(Player.class);
|
||||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
@@ -50,9 +50,12 @@ class TreeProgressListenerTest {
|
|||||||
automaticBreaks.mark(block);
|
automaticBreaks.mark(block);
|
||||||
listener.onBlockBreak(event);
|
listener.onBlockBreak(event);
|
||||||
automaticBreaks.unmark(block);
|
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);
|
listener.onBlockBreak(event);
|
||||||
|
|
||||||
assertEquals(1, states.state.progress(TreeSpecies.OAK));
|
assertEquals(2, states.state.progress(TreeSpecies.OAK));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class InMemoryStateStore implements PlayerStateStore {
|
private static final class InMemoryStateStore implements PlayerStateStore {
|
||||||
|
|||||||
Reference in New Issue
Block a user