feat(harvest): add bounded crop traversal and drops

This commit is contained in:
dmg
2026-08-14 18:26:03 -04:00
parent 9ddd69158f
commit 7a89119869
7 changed files with 175 additions and 2 deletions
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-003: Harvest connected mature crops" title: "US-003: Harvest connected mature crops"
description: Animate bounded harvesting across connected mature crops when a player performs a normal crop break. 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 # US-003: Harvest connected mature crops
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-004: Replant crops and distribute drops" title: "US-004: Replant crops and distribute drops"
description: Automatically replant harvested crops and deliver their remaining drops safely to the player. 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 # US-004: Replant crops and distribute drops
@@ -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);
}
}
@@ -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<BlockPosition> find(
BlockPosition origin,
Predicate<BlockPosition> eligible,
int limit
) {
if (limit <= 0 || !eligible.test(origin)) {
return List.of();
}
List<BlockPosition> result = new ArrayList<>(limit);
Queue<BlockPosition> queue = new ArrayDeque<>();
Set<BlockPosition> 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<BlockPosition> queue,
Set<BlockPosition> 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);
}
}
}
}
}
}
@@ -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<ItemStack> remainingDrops) {
public CropDropPlan {
remainingDrops = List.copyOf(remainingDrops);
}
public static CropDropPlan create(CropType crop, Collection<ItemStack> generatedDrops) {
List<ItemStack> 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());
}
}
@@ -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<BlockPosition> 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<BlockPosition> 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<BlockPosition> first = ConnectedCropSearch.find(origin, ignored -> true, 4);
List<BlockPosition> second = ConnectedCropSearch.find(origin, ignored -> true, 4);
assertEquals(first, second);
assertEquals(4, Set.copyOf(first).size());
assertEquals(origin, first.get(0));
}
}
@@ -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());
}
}