diff --git a/README.md b/README.md index 6c97d3d..040e35d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather ## A complete food journey -Crops are planted outdoors under open sky or beneath the glass roof of a greenhouse. A harvest can become flour, dough, bread, or part of a cooked dish. Milk and meat enter their own preparation chains, and the result can be served through furniture as well as carried as food items. Cooking connects these activities into a shared system for farmers, cooks, and diners. +Crops are planted outdoors under open sky, beneath the glass roof of a greenhouse, or under a low pergola of slabs, stairs and signs. A harvest can become flour, dough, bread, or part of a cooked dish. Milk and meat enter their own preparation chains, and the result can be served through furniture as well as carried as food items. Cooking connects these activities into a shared system for farmers, cooks, and diners. ## Documentation diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropPlantingRule.java b/src/main/java/net/tfminecraft/cooking/crops/CropPlantingRule.java index 40e5ca1..2d7aed2 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropPlantingRule.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropPlantingRule.java @@ -4,9 +4,14 @@ import java.util.HashSet; import java.util.Locale; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.type.Slab; import org.bukkit.configuration.ConfigurationSection; /** Food crops need an open column, with configurable greenhouse covers. */ @@ -20,34 +25,53 @@ public final class CropPlantingRule { private static boolean enabled = true; private static boolean allowGlass = true; private static Set allowedCover = Set.of(); + private static Set lowCover = Set.of(); + private static int lowCoverHeight = 0; private static Set exemptVanilla = Set.of(Material.NETHER_WART, Material.BROWN_MUSHROOM, Material.RED_MUSHROOM); private static Set exemptCustom = Set.of("yeast"); public static void configure(ConfigurationSection section) { enabled = section == null || section.getBoolean("require-open-sky", true); allowGlass = section == null || section.getBoolean("allow-glass-roofs", true); - allowedCover = materials(section, "allowed-cover", Set.of()); - exemptVanilla = materials(section, "exempt-vanilla", Set.of(Material.NETHER_WART, Material.BROWN_MUSHROOM, Material.RED_MUSHROOM)); + allowedCover = materials(section, "allowed-cover", "allowed-cover", Set.of()); + ConfigurationSection low = section == null ? null : section.getConfigurationSection("low-cover"); + lowCover = materials(low, "materials", "low-cover.materials", Set.of()); + lowCoverHeight = low == null ? 0 : Math.max(0, low.getInt("max-height", 5)); + exemptVanilla = materials(section, "exempt-vanilla", "exempt-vanilla", Set.of(Material.NETHER_WART, Material.BROWN_MUSHROOM, Material.RED_MUSHROOM)); exemptCustom = section == null || !section.contains("exempt-custom") ? Set.of("yeast") : Set.copyOf(section.getStringList("exempt-custom").stream().map(id -> id.toLowerCase(Locale.ROOT)).toList()); } - private static Set materials(ConfigurationSection section, String key, Set defaults) { + private static Set materials(ConfigurationSection section, String key, String label, Set defaults) { if (section == null || !section.contains(key)) { return defaults; } Set result = new HashSet<>(); for (String name : section.getStringList(key)) { - Material material = Material.matchMaterial(name); - if (material != null && material.isBlock()) { - result.add(material); + if (name.startsWith("#")) { + Tag tag = blockTag(name.substring(1)); + if (tag != null) { + result.addAll(tag.getValues()); + continue; + } } else { - org.bukkit.Bukkit.getLogger().warning("[Cooking] Invalid planting material in " + key + ": " + name); + Material material = Material.matchMaterial(name); + if (material != null && material.isBlock()) { + result.add(material); + continue; + } } + Bukkit.getLogger().warning("[Cooking] Invalid planting material in " + label + ": " + name); } return Set.copyOf(result); } + /** {@code #slabs} or {@code #minecraft:slabs}; null when the tag is unknown. */ + private static Tag blockTag(String name) { + NamespacedKey key = name.isEmpty() ? null : NamespacedKey.fromString(name.toLowerCase(Locale.ROOT)); + return key == null ? null : Bukkit.getTag(Tag.REGISTRY_BLOCKS, key, Material.class); + } + private CropPlantingRule() {} public static boolean requiresOpenSky(Material material) { @@ -65,13 +89,22 @@ public static boolean hasOpenSky(Location location) { if (world == null) { return false; } - for (int y = location.getBlockY() + 1; y < world.getMaxHeight(); y++) { - Material cover = world.getBlockAt(location.getBlockX(), y, location.getBlockZ()).getType(); + int crop = location.getBlockY(); + for (int y = crop + 1; y < world.getMaxHeight(); y++) { + Block block = world.getBlockAt(location.getBlockX(), y, location.getBlockZ()); + Material cover = block.getType(); if (!cover.isAir() && !allowedCover.contains(cover) - && !(allowGlass && (cover.name().endsWith("GLASS") || cover.name().endsWith("GLASS_PANE")))) { + && !(allowGlass && (cover.name().endsWith("GLASS") || cover.name().endsWith("GLASS_PANE"))) + && !(y - crop <= lowCoverHeight && isLowCover(block, cover))) { return false; } } return true; } + + /** Low decoration such as slabs, stairs and signs; a double slab is a full block, so it stays a roof. */ + private static boolean isLowCover(Block block, Material cover) { + return lowCover.contains(cover) + && !(block.getBlockData() instanceof Slab slab && slab.getType() == Slab.Type.DOUBLE); + } } diff --git a/src/main/resources/crops.yml b/src/main/resources/crops.yml index 8896654..94f46da 100644 --- a/src/main/resources/crops.yml +++ b/src/main/resources/crops.yml @@ -9,6 +9,11 @@ planting: allow-glass-roofs: true # Additional Bukkit block materials allowed anywhere above the crop. allowed-cover: [] + # Decoration allowed only within max-height blocks above the crop (pergolas, lantern posts). + # "#name" entries are vanilla block tags. Double slabs are full blocks and still count as a roof. + low-cover: + max-height: 5 + materials: ["#slabs", "#stairs", "#all_signs"] exempt-vanilla: [NETHER_WART, BROWN_MUSHROOM, RED_MUSHROOM] exempt-custom: [yeast] diff --git a/src/test/java/net/tfminecraft/cooking/crops/CropPlantingTest.java b/src/test/java/net/tfminecraft/cooking/crops/CropPlantingTest.java index d445b8a..58cd795 100644 --- a/src/test/java/net/tfminecraft/cooking/crops/CropPlantingTest.java +++ b/src/test/java/net/tfminecraft/cooking/crops/CropPlantingTest.java @@ -4,10 +4,18 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.data.type.Slab; import org.bukkit.entity.Player; import org.bukkit.event.block.BlockPlaceEvent; import org.junit.jupiter.api.BeforeEach; @@ -16,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.MockedStatic; import net.momirealms.customcrops.api.core.mechanic.crop.CropConfig; import net.momirealms.customcrops.api.event.CropPlantEvent; @@ -96,10 +105,11 @@ void wartMushroomsAndBuildingBlocksAreExempt() { assertTrue(CropPlantingRule.customRequiresOpenSky("rice")); } - private void roof(Material material, int y) { + private Block roof(Material material, int y) { Block block = mock(Block.class); when(block.getType()).thenReturn(material); when(world.getBlockAt(4, y, 8)).thenReturn(block); + return block; } @AfterEach @@ -136,4 +146,71 @@ void configurableCoverExemptionsAndDisable() { assertFalse(CropPlantingRule.requiresOpenSky(Material.CARROTS)); assertFalse(CropPlantingRule.customRequiresOpenSky("rice")); } + + @Test + void lowCoverCountsOnlyWithinItsHeight() { + YamlConfiguration config = new YamlConfiguration(); + config.set("low-cover.max-height", 5); + config.set("low-cover.materials", List.of("OAK_SLAB", "OAK_STAIRS", "OAK_SIGN")); + CropPlantingRule.configure(config); + roof(Material.OAK_SIGN, 65); + roof(Material.OAK_STAIRS, 67); + roof(Material.OAK_SLAB, 69); + assertTrue(CropPlantingRule.hasOpenSky(location)); + roof(Material.OAK_SLAB, 70); + assertFalse(CropPlantingRule.hasOpenSky(location)); + roof(Material.AIR, 70); + roof(Material.STONE, 90); + assertFalse(CropPlantingRule.hasOpenSky(location)); + } + + @Test + void doubleSlabsStayARoofAndMissingLowCoverChangesNothing() { + YamlConfiguration config = new YamlConfiguration(); + config.set("low-cover.materials", List.of("OAK_SLAB")); + CropPlantingRule.configure(config); + Block slab = roof(Material.OAK_SLAB, 66); + Slab data = mock(Slab.class); + when(slab.getBlockData()).thenReturn(data); + when(data.getType()).thenReturn(Slab.Type.TOP); + assertTrue(CropPlantingRule.hasOpenSky(location), "max-height defaults to 5"); + when(data.getType()).thenReturn(Slab.Type.DOUBLE); + assertFalse(CropPlantingRule.hasOpenSky(location)); + + CropPlantingRule.configure(new YamlConfiguration()); + when(data.getType()).thenReturn(Slab.Type.BOTTOM); + assertFalse(CropPlantingRule.hasOpenSky(location)); + config.set("low-cover.max-height", -3); + CropPlantingRule.configure(config); + assertFalse(CropPlantingRule.hasOpenSky(location)); + } + + @Test + @SuppressWarnings("unchecked") + void lowCoverReadsBlockTagsAndWarnsOnUnknownEntries() { + Tag slabs = mock(Tag.class); + when(slabs.getValues()).thenReturn(Set.of(Material.OAK_SLAB, Material.STONE_SLAB)); + Tag signs = mock(Tag.class); + when(signs.getValues()).thenReturn(Set.of(Material.OAK_SIGN, Material.OAK_WALL_HANGING_SIGN)); + Logger logger = mock(Logger.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getLogger).thenReturn(logger); + bukkit.when(() -> Bukkit.getTag(Tag.REGISTRY_BLOCKS, NamespacedKey.minecraft("slabs"), Material.class)) + .thenReturn(slabs); + bukkit.when(() -> Bukkit.getTag(Tag.REGISTRY_BLOCKS, NamespacedKey.minecraft("all_signs"), Material.class)) + .thenReturn(signs); + YamlConfiguration config = new YamlConfiguration(); + config.set("low-cover.materials", List.of("#SLABS", "#minecraft:all_signs", "#nope", "#", "#bad key", "DIAMOND")); + CropPlantingRule.configure(config); + verify(logger).warning("[Cooking] Invalid planting material in low-cover.materials: #nope"); + verify(logger).warning("[Cooking] Invalid planting material in low-cover.materials: #"); + verify(logger).warning("[Cooking] Invalid planting material in low-cover.materials: #bad key"); + verify(logger).warning("[Cooking] Invalid planting material in low-cover.materials: DIAMOND"); + } + roof(Material.STONE_SLAB, 65); + roof(Material.OAK_WALL_HANGING_SIGN, 66); + assertTrue(CropPlantingRule.hasOpenSky(location)); + roof(Material.OAK_STAIRS, 67); + assertFalse(CropPlantingRule.hasOpenSky(location)); + } }