feat(progress): add species unlock progression
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user