From 7a89119869bc9393dfbed76a65fa5ab577688fbf Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 18:26:03 -0400 Subject: [PATCH] feat(harvest): add bounded crop traversal and drops --- .../us-003-harvest-connected-mature-crops.md | 2 +- ...-004-replant-crops-and-distribute-drops.md | 2 +- .../dmg/spigotharvest/BlockPosition.java | 8 +++ .../spigotharvest/ConnectedCropSearch.java | 59 +++++++++++++++++++ .../games/dmg/spigotharvest/CropDropPlan.java | 29 +++++++++ .../ConnectedCropSearchTest.java | 39 ++++++++++++ .../dmg/spigotharvest/CropDropPlanTest.java | 38 ++++++++++++ 7 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/main/java/games/dmg/spigotharvest/BlockPosition.java create mode 100644 src/main/java/games/dmg/spigotharvest/ConnectedCropSearch.java create mode 100644 src/main/java/games/dmg/spigotharvest/CropDropPlan.java create mode 100644 src/test/java/games/dmg/spigotharvest/ConnectedCropSearchTest.java create mode 100644 src/test/java/games/dmg/spigotharvest/CropDropPlanTest.java diff --git a/design/user-stories/us-003-harvest-connected-mature-crops.md b/design/user-stories/us-003-harvest-connected-mature-crops.md index 1e812c1..0494cf3 100644 --- a/design/user-stories/us-003-harvest-connected-mature-crops.md +++ b/design/user-stories/us-003-harvest-connected-mature-crops.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Harvest connected mature crops" description: Animate bounded harvesting across connected mature crops when a player performs a normal crop break. -status: backlog +status: in-progress --- # US-003: Harvest connected mature crops diff --git a/design/user-stories/us-004-replant-crops-and-distribute-drops.md b/design/user-stories/us-004-replant-crops-and-distribute-drops.md index 4ae3178..17405ba 100644 --- a/design/user-stories/us-004-replant-crops-and-distribute-drops.md +++ b/design/user-stories/us-004-replant-crops-and-distribute-drops.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Replant crops and distribute drops" description: Automatically replant harvested crops and deliver their remaining drops safely to the player. -status: backlog +status: in-progress --- # US-004: Replant crops and distribute drops diff --git a/src/main/java/games/dmg/spigotharvest/BlockPosition.java b/src/main/java/games/dmg/spigotharvest/BlockPosition.java new file mode 100644 index 0000000..70be6f7 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/BlockPosition.java @@ -0,0 +1,8 @@ +package games.dmg.spigotharvest; + +/** World-local integer block coordinates. */ +public record BlockPosition(int x, int y, int z) { + public BlockPosition offset(int deltaX, int deltaY, int deltaZ) { + return new BlockPosition(x + deltaX, y + deltaY, z + deltaZ); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/ConnectedCropSearch.java b/src/main/java/games/dmg/spigotharvest/ConnectedCropSearch.java new file mode 100644 index 0000000..93a1796 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/ConnectedCropSearch.java @@ -0,0 +1,59 @@ +package games.dmg.spigotharvest; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.function.Predicate; + +/** Deterministic bounded search over diagonally and gradually connected crops. */ +public final class ConnectedCropSearch { + private ConnectedCropSearch() { + } + + public static List find( + BlockPosition origin, + Predicate eligible, + int limit + ) { + if (limit <= 0 || !eligible.test(origin)) { + return List.of(); + } + List result = new ArrayList<>(limit); + Queue queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(origin); + visited.add(origin); + while (!queue.isEmpty() && result.size() < limit) { + BlockPosition current = queue.remove(); + if (!eligible.test(current)) { + continue; + } + result.add(current); + enqueueNeighbors(current, queue, visited); + } + return List.copyOf(result); + } + + private static void enqueueNeighbors( + BlockPosition position, + Queue queue, + Set visited + ) { + for (int deltaY = -1; deltaY <= 1; deltaY++) { + for (int deltaX = -1; deltaX <= 1; deltaX++) { + for (int deltaZ = -1; deltaZ <= 1; deltaZ++) { + if (deltaX == 0 && deltaZ == 0) { + continue; + } + BlockPosition neighbor = position.offset(deltaX, deltaY, deltaZ); + if (visited.add(neighbor)) { + queue.add(neighbor); + } + } + } + } + } +} diff --git a/src/main/java/games/dmg/spigotharvest/CropDropPlan.java b/src/main/java/games/dmg/spigotharvest/CropDropPlan.java new file mode 100644 index 0000000..5e3d388 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/CropDropPlan.java @@ -0,0 +1,29 @@ +package games.dmg.spigotharvest; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.bukkit.inventory.ItemStack; + +/** Drop result after reserving exactly one item for replanting. */ +public record CropDropPlan(boolean canReplant, List remainingDrops) { + public CropDropPlan { + remainingDrops = List.copyOf(remainingDrops); + } + + public static CropDropPlan create(CropType crop, Collection generatedDrops) { + List drops = new ArrayList<>(); + boolean consumed = false; + for (ItemStack generated : generatedDrops) { + ItemStack item = generated.clone(); + if (!consumed && item.getType() == crop.plantingMaterial() && item.getAmount() > 0) { + item.setAmount(item.getAmount() - 1); + consumed = true; + } + if (item.getAmount() > 0) { + drops.add(item); + } + } + return consumed ? new CropDropPlan(true, drops) : new CropDropPlan(false, List.of()); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/ConnectedCropSearchTest.java b/src/test/java/games/dmg/spigotharvest/ConnectedCropSearchTest.java new file mode 100644 index 0000000..9742d39 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/ConnectedCropSearchTest.java @@ -0,0 +1,39 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +final class ConnectedCropSearchTest { + @Test + void followsEightDirectionsAndOneVerticalBlockPerConnection() { + BlockPosition origin = new BlockPosition(0, 64, 0); + Set mature = Set.of( + origin, + new BlockPosition(1, 65, 1), + new BlockPosition(2, 66, 2), + new BlockPosition(3, 68, 3), + new BlockPosition(-1, 63, 0) + ); + + List result = ConnectedCropSearch.find(origin, mature::contains, 10); + + assertEquals(4, result.size()); + assertTrue(result.contains(new BlockPosition(2, 66, 2))); + assertTrue(!result.contains(new BlockPosition(3, 68, 3))); + } + + @Test + void isDeterministicUniqueAndBounded() { + BlockPosition origin = new BlockPosition(0, 64, 0); + List first = ConnectedCropSearch.find(origin, ignored -> true, 4); + List second = ConnectedCropSearch.find(origin, ignored -> true, 4); + + assertEquals(first, second); + assertEquals(4, Set.copyOf(first).size()); + assertEquals(origin, first.get(0)); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/CropDropPlanTest.java b/src/test/java/games/dmg/spigotharvest/CropDropPlanTest.java new file mode 100644 index 0000000..f2bf49d --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/CropDropPlanTest.java @@ -0,0 +1,38 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +final class CropDropPlanTest { + @Test + void consumesExactlyOnePlantingItemAndKeepsRemainingDrops() { + CropDropPlan plan = CropDropPlan.create( + CropType.WHEAT, + List.of(new ItemStack(Material.WHEAT, 1), new ItemStack(Material.WHEAT_SEEDS, 3)) + ); + + assertTrue(plan.canReplant()); + assertEquals(2, plan.remainingDrops().stream() + .filter(item -> item.getType() == Material.WHEAT_SEEDS) + .mapToInt(ItemStack::getAmount).sum()); + assertEquals(1, plan.remainingDrops().stream() + .filter(item -> item.getType() == Material.WHEAT) + .mapToInt(ItemStack::getAmount).sum()); + } + + @Test + void refusesHarvestWhenDropsCannotReplant() { + CropDropPlan plan = CropDropPlan.create( + CropType.BEETROOT, + List.of(new ItemStack(Material.BEETROOT, 2)) + ); + + assertTrue(!plan.canReplant()); + assertTrue(plan.remainingDrops().isEmpty()); + } +}