feat(progress): add species unlock progression

This commit is contained in:
dmg
2026-08-11 17:18:57 -04:00
parent 12a8e84535
commit 8067b3ebfb
23 changed files with 686 additions and 13 deletions
+8
View File
@@ -34,3 +34,11 @@
- Added UUID-keyed immutable player state with retained names, saturating progress, unlocks, preferences, locks, defensive reads, forward-field retention, and atomic YAML replacement. - 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. - 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`. - Verified settings, persistence, corruption handling, atomic replacement, and the complete build with `./gradlew clean check jar`.
### US-001 species unlock progression completed
- Added stable taxonomy for all approved overworld trees, Nether fungi, and giant mushrooms while excluding bamboo and treating azalea logs as oak.
- Replaced the old recursive search with a deterministic, bounded, iterative scanner that follows connected trunk blocks laterally and upward but never downward and requires matching foliage or caps.
- Added Survival-and-axe eligibility, automatic-break suppression, durable one-point increments, saturating counters, permanent unlocks, and next-qualifying-block threshold evaluation.
- Registered progress handling through the Spigot block-break lifecycle and persisted every accepted update before notifying observers.
- Verified taxonomy, detection, tools, eligibility, progression, persistence, and the complete build with `./gradlew clean check jar`.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-001: Earn tree-type unlocks" title: "US-001: Earn tree-type unlocks"
description: Let players earn permanent automatic felling separately for each supported tree species. description: Let players earn permanent automatic felling separately for each supported tree species.
status: backlog status: done
--- ---
# US-001: Earn tree-type unlocks # US-001: Earn tree-type unlocks
@@ -11,17 +11,17 @@ As a **survival player**, I want to unlock Tree Feller by practicing with each t
## Acceptance criteria ## 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. - [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.
- [ ] 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.
- [ ] 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 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. - [x] 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. - [x] 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. - [x] Each species has an independently configurable unlock threshold that defaults to 100 blocks.
- [ ] Reaching the active threshold permanently unlocks automatic felling for that species. - [x] 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. - [x] A threshold lowered below a player's saved progress grants the unlock when that player next mines a qualifying block of the species, not immediately when configuration changes.
- [ ] Raising a threshold never removes an earned unlock. - [x] 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. - [x] A player whose automatic felling is disabled or administratively locked may continue earning progress through qualifying manual mining.
- [ ] Progress and unlocks survive logout and server restart. - [x] Progress and unlocks survive logout and server restart.
## Related ## Related
@@ -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,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,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,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,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);
}
@@ -1,12 +1,15 @@
package games.dmg.treefeller; package games.dmg.treefeller;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
/** Entry point for Tree Feller. */ /** Entry point for Tree Feller. */
public final class TreeFellerPlugin extends JavaPlugin { public final class TreeFellerPlugin extends JavaPlugin {
private TreeFellerSettingsService settingsService; private TreeFellerSettingsService settingsService;
private YamlPlayerStateRepository playerStateRepository; private YamlPlayerStateRepository playerStateRepository;
private AutomaticBreakRegistry automaticBreakRegistry;
private TreeDetector treeDetector;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -17,6 +20,18 @@ public final class TreeFellerPlugin extends JavaPlugin {
settings, new BukkitThresholdPersistence(this)); settings, new BukkitThresholdPersistence(this));
Path playerStateFile = getDataFolder().toPath().resolve("players.yml"); Path playerStateFile = getDataFolder().toPath().resolve("players.yml");
playerStateRepository = new YamlPlayerStateRepository(playerStateFile); playerStateRepository = new YamlPlayerStateRepository(playerStateFile);
automaticBreakRegistry = new AutomaticBreakRegistry();
treeDetector = new BukkitTreeDetector(new TreeStructureScanner(
settings.maxSearchBlocks(), settings.maxSearchDistance()));
TreeProgressListener progressListener = new TreeProgressListener(
treeDetector,
playerStateRepository,
species -> settingsService.current().threshold(species),
automaticBreakRegistry,
(player, update) -> { },
exception -> getLogger().log(
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
getServer().getPluginManager().registerEvents(progressListener, this);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage()); getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this); getServer().getPluginManager().disablePlugin(this);
@@ -30,4 +45,12 @@ public final class TreeFellerPlugin extends JavaPlugin {
public YamlPlayerStateRepository playerStateRepository() { public YamlPlayerStateRepository playerStateRepository() {
return playerStateRepository; return playerStateRepository;
} }
public AutomaticBreakRegistry automaticBreakRegistry() {
return automaticBreakRegistry;
}
public TreeDetector treeDetector() {
return treeDetector;
}
} }
@@ -0,0 +1,67 @@
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
|| !TreeTools.isAxe(player.getInventory().getItemInMainHand().getType())
|| automaticBreaks.isMarked(event.getBlock())) {
return;
}
Optional<TreeStructure> detected = detector.detect(event.getBlock());
if (detected.isEmpty()) {
return;
}
TreeSpecies species = detected.orElseThrow().species();
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,23 @@
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.IRON_AXE,
Material.GOLDEN_AXE,
Material.DIAMOND_AXE,
Material.NETHERITE_AXE);
private TreeTools() {
}
public static boolean isAxe(Material material) {
return AXES.contains(material);
}
}
@@ -19,7 +19,7 @@ import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
/** UUID-keyed YAML persistence that retains fields it does not own. */ /** UUID-keyed YAML persistence that retains fields it does not own. */
public final class YamlPlayerStateRepository { public final class YamlPlayerStateRepository implements PlayerStateStore {
private final Path file; private final Path file;
private final YamlConfiguration document; private final YamlConfiguration document;
@@ -30,6 +30,7 @@ public final class YamlPlayerStateRepository {
: new YamlConfiguration(); : new YamlConfiguration();
} }
@Override
public Optional<PlayerTreeFellerState> load(UUID playerId) { public Optional<PlayerTreeFellerState> load(UUID playerId) {
ConfigurationSection record = document.getConfigurationSection(path(playerId)); ConfigurationSection record = document.getConfigurationSection(path(playerId));
if (record == null) { if (record == null) {
@@ -59,6 +60,7 @@ public final class YamlPlayerStateRepository {
return List.copyOf(states); return List.copyOf(states);
} }
@Override
public void save(PlayerTreeFellerState state) throws IOException { public void save(PlayerTreeFellerState state) throws IOException {
String root = path(state.playerId()); String root = path(state.playerId());
document.set(root + ".latest-name", state.latestName()); document.set(root + ".latest-name", state.latestName());
@@ -0,0 +1,71 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeProgressListenerTest {
@Test
void recordsOnlyManualSurvivalAxeBreaksFromValidatedTrees() 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(world.getUID()).thenReturn(UUID.randomUUID());
when(block.getX()).thenReturn(1);
when(block.getY()).thenReturn(64);
when(block.getZ()).thenReturn(2);
TreeStructure tree = new TreeStructure(TreeSpecies.OAK, List.of(new BlockPoint(0, 0, 0)));
InMemoryStateStore states = new InMemoryStateStore();
AutomaticBreakRegistry automaticBreaks = new AutomaticBreakRegistry();
TreeProgressListener listener = new TreeProgressListener(
ignored -> Optional.of(tree),
states,
ignored -> 100,
automaticBreaks,
(ignoredPlayer, ignoredUpdate) -> { },
ignored -> { });
BlockBreakEvent event = new BlockBreakEvent(block, player);
automaticBreaks.mark(block);
listener.onBlockBreak(event);
automaticBreaks.unmark(block);
listener.onBlockBreak(event);
assertEquals(1, states.state.progress(TreeSpecies.OAK));
}
private static final class InMemoryStateStore implements PlayerStateStore {
private PlayerTreeFellerState state;
@Override
public Optional<PlayerTreeFellerState> load(UUID playerId) {
return Optional.ofNullable(state);
}
@Override
public void save(PlayerTreeFellerState changed) {
state = changed;
}
}
}
@@ -0,0 +1,49 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class TreeProgressTrackerTest {
@Test
void unlocksExactlyWhenTheIncrementedProgressReachesTheThreshold() {
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
.withProgress(TreeSpecies.OAK, 99);
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.OAK, 100);
assertEquals(100, update.state().progress(TreeSpecies.OAK));
assertTrue(update.state().isUnlocked(TreeSpecies.OAK));
assertTrue(update.newlyUnlocked());
}
@Test
void appliesALoweredThresholdOnlyOnTheNextQualifyingBlock() {
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
.withEnabled(false)
.withLocked(true)
.withProgress(TreeSpecies.SPRUCE, 80);
assertFalse(state.isUnlocked(TreeSpecies.SPRUCE));
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.SPRUCE, 50);
assertEquals(81, update.state().progress(TreeSpecies.SPRUCE));
assertTrue(update.state().isUnlocked(TreeSpecies.SPRUCE));
}
@Test
void neverRevokesOrReannouncesAnExistingUnlock() {
PlayerTreeFellerState state = PlayerTreeFellerState.initial(UUID.randomUUID(), "Player")
.withProgress(TreeSpecies.BIRCH, 100)
.withUnlocked(TreeSpecies.BIRCH, true);
ProgressUpdate update = TreeProgressTracker.record(state, TreeSpecies.BIRCH, 500);
assertTrue(update.state().isUnlocked(TreeSpecies.BIRCH));
assertFalse(update.newlyUnlocked());
assertEquals(101, update.state().progress(TreeSpecies.BIRCH));
}
}
@@ -0,0 +1,67 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashMap;
import java.util.Map;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
class TreeStructureScannerTest {
@Test
void recognizesAConnectedLeafBearingTreeWithoutFollowingWoodBelowTheChop() {
TestBlocks blocks = new TestBlocks();
blocks.put(0, -1, 0, Material.OAK_LOG);
blocks.put(0, 0, 0, Material.OAK_LOG);
blocks.put(0, 1, 0, Material.OAK_LOG);
blocks.put(1, 2, 0, Material.OAK_LOG);
blocks.put(1, 2, 1, Material.OAK_LEAVES);
TreeStructureScanner scanner = new TreeStructureScanner(100, 16);
TreeStructure tree = scanner.scan(blocks, new BlockPoint(0, 0, 0)).orElseThrow();
assertEquals(TreeSpecies.OAK, tree.species());
assertEquals(3, tree.trunkBlocks().size());
assertFalse(tree.trunkBlocks().contains(new BlockPoint(0, -1, 0)));
}
@Test
void rejectsAConnectedLogStructureWithoutSpeciesFoliage() {
TestBlocks blocks = new TestBlocks();
blocks.put(0, 0, 0, Material.SPRUCE_LOG);
blocks.put(0, 1, 0, Material.SPRUCE_LOG);
assertTrue(new TreeStructureScanner(100, 16)
.scan(blocks, new BlockPoint(0, 0, 0))
.isEmpty());
}
@Test
void distinguishesGiantMushroomsByTheirCaps() {
TestBlocks blocks = new TestBlocks();
blocks.put(0, 0, 0, Material.MUSHROOM_STEM);
blocks.put(0, 1, 0, Material.MUSHROOM_STEM);
blocks.put(1, 1, 0, Material.RED_MUSHROOM_BLOCK);
TreeStructure tree = new TreeStructureScanner(100, 16)
.scan(blocks, new BlockPoint(0, 0, 0))
.orElseThrow();
assertEquals(TreeSpecies.RED_MUSHROOM, tree.species());
}
private static final class TestBlocks implements BlockAccess {
private final Map<BlockPoint, Material> materials = new HashMap<>();
void put(int x, int y, int z, Material material) {
materials.put(new BlockPoint(x, y, z), material);
}
@Override
public Material materialAt(BlockPoint point) {
return materials.getOrDefault(point, Material.AIR);
}
}
}
@@ -0,0 +1,30 @@
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());
}
}
@@ -0,0 +1,18 @@
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.NETHERITE_AXE));
assertFalse(TreeTools.isAxe(Material.DIAMOND_PICKAXE));
assertFalse(TreeTools.isAxe(Material.AIR));
}
}