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
@@ -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;
import java.nio.file.Path;
import java.util.logging.Level;
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;
@Override
public void onEnable() {
@@ -17,6 +20,18 @@ 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()));
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) {
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
@@ -30,4 +45,12 @@ public final class TreeFellerPlugin extends JavaPlugin {
public YamlPlayerStateRepository 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;
/** 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 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) {
@@ -59,6 +60,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,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));
}
}