Skip to content

Commit 01298b4

Browse files
authored
fix: prevent food crops being planted indoors or underground (#62)
* fix: require open sky when planting food crops * feat: allow greenhouses and configure crop planting restrictions --------- Co-authored-by: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com>
1 parent a611e85 commit 01298b4

8 files changed

Lines changed: 265 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Cooking makes food an interactive part of everyday life. Players grow and gather
1515

1616
## A complete food journey
1717

18+
Food crops need an open column above them. Cave ceilings and building roofs prevent vanilla and CustomCrops planting, including automatic replanting. Glass greenhouses are allowed by default. Nether wart, mushrooms, and the CustomCrops yeast crop can still be planted indoors.
19+
20+
The `planting` section of `crops.yml` controls `require-open-sky`, `allow-glass-roofs`, additional `allowed-cover` block materials, and `exempt-vanilla` / `exempt-custom` crop lists. Lists replace their defaults; an empty exemption list requires open sky for those crops too. Reload with `cooking reload`. Existing configs without this section use the defaults above. Adding a roof after planting does not remove existing crops or change their growth rules.
21+
1822
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.
1923

2024
## Documentation

‎src/main/java/net/tfminecraft/cooking/Cooking.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import net.tfminecraft.cooking.fishing.LegacyFishScan;
3535
import net.tfminecraft.cooking.fishing.CustomFishingCatalog;
3636
import net.tfminecraft.cooking.crops.CropGrowthListener;
37+
import net.tfminecraft.cooking.crops.CropPlantingListener;
3738
import net.tfminecraft.cooking.crops.CropsLoader;
3839
import net.tfminecraft.cooking.husbandry.HusbandryLoader;
3940
import net.tfminecraft.cooking.husbandry.HusbandryLocator;
@@ -237,6 +238,7 @@ public void registerListeners() {
237238
getServer().getPluginManager().registerEvents(new FarmHarvestListener(), this);
238239
getServer().getPluginManager().registerEvents(new FarmTrampleListener(), this);
239240
getServer().getPluginManager().registerEvents(new CropGrowthListener(), this);
241+
getServer().getPluginManager().registerEvents(new CropPlantingListener(), this);
240242
CropCustomCropsBridge customCropsBridge = new CropCustomCropsBridge();
241243
getServer().getPluginManager().registerEvents(customCropsBridge, this);
242244
CropCustomCropsBridge.tryRegister(this);

‎src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import net.momirealms.customcrops.api.core.world.CustomCropsBlockState;
2222
import net.momirealms.customcrops.api.event.CropBreakEvent;
2323
import net.momirealms.customcrops.api.event.CropInteractEvent;
24+
import net.momirealms.customcrops.api.event.CropPlantEvent;
2425

2526
import net.tfminecraft.cooking.Cooking;
2627

@@ -31,6 +32,15 @@ public final class CropCustomCropsListener implements Listener {
3132

3233
private final Map<String, PendingHarvest> pending = new ConcurrentHashMap<>();
3334

35+
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
36+
public void onPlant(CropPlantEvent event) {
37+
if (CropPlantingRule.customRequiresOpenSky(event.cropConfig().id())
38+
&& !CropPlantingRule.hasOpenSky(event.location())) {
39+
event.setCancelled(true);
40+
event.getPlayer().sendMessage(CropPlantingListener.DENIAL_MESSAGE);
41+
}
42+
}
43+
3444
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
3545
public void onBreak(CropBreakEvent event) {
3646
CropDefinition crop = customCrop(event.cropConfig());
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package net.tfminecraft.cooking.crops;
2+
3+
import org.bukkit.ChatColor;
4+
import org.bukkit.block.Block;
5+
import org.bukkit.event.EventHandler;
6+
import org.bukkit.event.EventPriority;
7+
import org.bukkit.event.Listener;
8+
import org.bukkit.event.block.BlockPlaceEvent;
9+
10+
public final class CropPlantingListener implements Listener {
11+
public static final String DENIAL_MESSAGE = ChatColor.RED + "Crops must be planted outdoors with open sky above them.";
12+
13+
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
14+
public void onPlant(BlockPlaceEvent event) {
15+
Block crop = event.getBlockPlaced();
16+
if (CropPlantingRule.requiresOpenSky(crop.getType())
17+
&& !CropPlantingRule.hasOpenSky(crop.getLocation())) {
18+
event.setCancelled(true);
19+
event.getPlayer().sendMessage(DENIAL_MESSAGE);
20+
}
21+
}
22+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package net.tfminecraft.cooking.crops;
2+
3+
import java.util.Set;
4+
import java.util.HashSet;
5+
import java.util.Locale;
6+
7+
import org.bukkit.Location;
8+
import org.bukkit.Material;
9+
import org.bukkit.World;
10+
import org.bukkit.configuration.ConfigurationSection;
11+
12+
/** Food crops need an open column, with configurable greenhouse covers. */
13+
public final class CropPlantingRule {
14+
private static final Set<Material> OUTDOOR_CROPS = Set.of(
15+
Material.WHEAT, Material.POTATOES, Material.CARROTS, Material.BEETROOTS,
16+
Material.MELON_STEM, Material.PUMPKIN_STEM, Material.SUGAR_CANE,
17+
Material.CACTUS, Material.COCOA, Material.SWEET_BERRY_BUSH,
18+
Material.TORCHFLOWER_CROP, Material.PITCHER_CROP);
19+
20+
private static boolean enabled = true;
21+
private static boolean allowGlass = true;
22+
private static Set<Material> allowedCover = Set.of();
23+
private static Set<Material> exemptVanilla = Set.of(Material.NETHER_WART, Material.BROWN_MUSHROOM, Material.RED_MUSHROOM);
24+
private static Set<String> exemptCustom = Set.of("yeast");
25+
26+
public static void configure(ConfigurationSection section) {
27+
enabled = section == null || section.getBoolean("require-open-sky", true);
28+
allowGlass = section == null || section.getBoolean("allow-glass-roofs", true);
29+
allowedCover = materials(section, "allowed-cover", Set.of());
30+
exemptVanilla = materials(section, "exempt-vanilla", Set.of(Material.NETHER_WART, Material.BROWN_MUSHROOM, Material.RED_MUSHROOM));
31+
exemptCustom = section == null || !section.contains("exempt-custom") ? Set.of("yeast")
32+
: Set.copyOf(section.getStringList("exempt-custom").stream().map(id -> id.toLowerCase(Locale.ROOT)).toList());
33+
}
34+
35+
private static Set<Material> materials(ConfigurationSection section, String key, Set<Material> defaults) {
36+
if (section == null || !section.contains(key)) {
37+
return defaults;
38+
}
39+
Set<Material> result = new HashSet<>();
40+
for (String name : section.getStringList(key)) {
41+
Material material = Material.matchMaterial(name);
42+
if (material != null && material.isBlock()) {
43+
result.add(material);
44+
} else {
45+
org.bukkit.Bukkit.getLogger().warning("[Cooking] Invalid planting material in " + key + ": " + name);
46+
}
47+
}
48+
return Set.copyOf(result);
49+
}
50+
51+
private CropPlantingRule() {}
52+
53+
public static boolean requiresOpenSky(Material material) {
54+
return enabled && (OUTDOOR_CROPS.contains(material)
55+
|| material == Material.NETHER_WART || material == Material.BROWN_MUSHROOM || material == Material.RED_MUSHROOM)
56+
&& !exemptVanilla.contains(material);
57+
}
58+
59+
public static boolean customRequiresOpenSky(String cropId) {
60+
return enabled && (cropId == null || !exemptCustom.contains(cropId.toLowerCase(Locale.ROOT)));
61+
}
62+
63+
public static boolean hasOpenSky(Location location) {
64+
World world = location.getWorld();
65+
if (world == null) {
66+
return false;
67+
}
68+
for (int y = location.getBlockY() + 1; y < world.getMaxHeight(); y++) {
69+
Material cover = world.getBlockAt(location.getBlockX(), y, location.getBlockZ()).getType();
70+
if (!cover.isAir() && !allowedCover.contains(cover)
71+
&& !(allowGlass && (cover.name().endsWith("GLASS") || cover.name().endsWith("GLASS_PANE")))) {
72+
return false;
73+
}
74+
}
75+
return true;
76+
}
77+
}

‎src/main/java/net/tfminecraft/cooking/crops/CropsLoader.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ public void load(File file) {
2222
} catch (IOException | InvalidConfigurationException ex) {
2323
Bukkit.getLogger().warning("[Cooking] Failed to load crops.yml: " + ex.getMessage());
2424
CropsConfig.apply(Map.of(), Map.of(), Map.of(), true);
25+
CropPlantingRule.configure(null);
2526
return;
2627
}
2728

29+
CropPlantingRule.configure(config.getConfigurationSection("planting"));
2830
ConfigurationSection harvest = config.getConfigurationSection("harvest-quality");
2931
boolean growthGate = true;
3032
ConfigurationSection growth = config.getConfigurationSection("growth-gate");

‎src/main/resources/crops.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
# Growth ticks use the same affection: chance = (fertility/100) ^ affection.
44
# Seeds stay untagged IA/vanilla. CustomCrops one-seed drops stay yield/balance.
55

6+
planting:
7+
require-open-sky: true
8+
# Greenhouses: clear, stained and tinted glass, including panes, are allowed.
9+
allow-glass-roofs: true
10+
# Additional Bukkit block materials allowed anywhere above the crop.
11+
allowed-cover: []
12+
exempt-vanilla: [NETHER_WART, BROWN_MUSHROOM, RED_MUSHROOM]
13+
exempt-custom: [yeast]
14+
615
growth-gate:
716
enabled: true
817

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package net.tfminecraft.cooking.crops;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
import static org.mockito.ArgumentMatchers.*;
5+
import static org.mockito.Mockito.*;
6+
7+
import org.bukkit.Location;
8+
import org.bukkit.Material;
9+
import org.bukkit.World;
10+
import org.bukkit.block.Block;
11+
import org.bukkit.entity.Player;
12+
import org.bukkit.event.block.BlockPlaceEvent;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.bukkit.configuration.file.YamlConfiguration;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.params.ParameterizedTest;
18+
import org.junit.jupiter.params.provider.EnumSource;
19+
20+
import net.momirealms.customcrops.api.core.mechanic.crop.CropConfig;
21+
import net.momirealms.customcrops.api.event.CropPlantEvent;
22+
23+
class CropPlantingTest {
24+
private World world;
25+
private Location location;
26+
27+
@BeforeEach
28+
void setup() {
29+
CropPlantingRule.configure(null);
30+
world = mock(World.class);
31+
location = new Location(world, 4, 64, 8);
32+
when(world.getMaxHeight()).thenReturn(320);
33+
Block air = mock(Block.class);
34+
when(air.getType()).thenReturn(Material.AIR);
35+
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
36+
}
37+
38+
@Test
39+
void outdoorsWorksAtNightAndBelowSeaLevel() {
40+
assertTrue(CropPlantingRule.hasOpenSky(location));
41+
assertTrue(CropPlantingRule.hasOpenSky(new Location(world, 4, -20, 8)));
42+
}
43+
44+
@ParameterizedTest
45+
@EnumSource(value = Material.class, names = {"STONE", "OAK_PLANKS", "OAK_SLAB", "OAK_LEAVES"})
46+
void caveAndHouseRoofsCancelVanillaPlanting(Material roof) {
47+
roof(roof, 90);
48+
Block crop = mock(Block.class);
49+
when(crop.getType()).thenReturn(Material.WHEAT);
50+
when(crop.getLocation()).thenReturn(location);
51+
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
52+
Player player = mock(Player.class);
53+
when(event.getBlockPlaced()).thenReturn(crop);
54+
when(event.getPlayer()).thenReturn(player);
55+
new CropPlantingListener().onPlant(event);
56+
verify(event).setCancelled(true);
57+
verify(player).sendMessage(CropPlantingListener.DENIAL_MESSAGE);
58+
}
59+
60+
@Test
61+
void checksRoofAtTopOfWorldButIgnoresCropItself() {
62+
roof(Material.WHEAT, 64);
63+
assertTrue(CropPlantingRule.hasOpenSky(location));
64+
roof(Material.STONE, 319);
65+
assertFalse(CropPlantingRule.hasOpenSky(location));
66+
}
67+
68+
@Test
69+
void customCropsCancelUnderStoneButYeastIsExempt() {
70+
roof(Material.STONE, 66);
71+
CropPlantEvent event = mock(CropPlantEvent.class);
72+
CropConfig config = mock(CropConfig.class);
73+
when(config.id()).thenReturn("tomato");
74+
when(event.cropConfig()).thenReturn(config);
75+
when(event.location()).thenReturn(location);
76+
when(event.getPlayer()).thenReturn(mock(Player.class));
77+
new CropCustomCropsListener().onPlant(event);
78+
verify(event).setCancelled(true);
79+
80+
reset(event);
81+
when(config.id()).thenReturn("yeast");
82+
when(event.cropConfig()).thenReturn(config);
83+
new CropCustomCropsListener().onPlant(event);
84+
verify(event, never()).setCancelled(anyBoolean());
85+
}
86+
87+
@Test
88+
void wartMushroomsAndBuildingBlocksAreExempt() {
89+
for (Material material : new Material[] {Material.NETHER_WART, Material.BROWN_MUSHROOM,
90+
Material.RED_MUSHROOM, Material.OAK_PLANKS}) {
91+
assertFalse(CropPlantingRule.requiresOpenSky(material));
92+
}
93+
assertTrue(CropPlantingRule.requiresOpenSky(Material.MELON_STEM));
94+
assertTrue(CropPlantingRule.requiresOpenSky(Material.PUMPKIN_STEM));
95+
assertFalse(CropPlantingRule.customRequiresOpenSky("yeast"));
96+
assertTrue(CropPlantingRule.customRequiresOpenSky("rice"));
97+
}
98+
99+
private void roof(Material material, int y) {
100+
Block block = mock(Block.class);
101+
when(block.getType()).thenReturn(material);
102+
when(world.getBlockAt(4, y, 8)).thenReturn(block);
103+
}
104+
105+
@AfterEach
106+
void resetConfig() {
107+
CropPlantingRule.configure(null);
108+
}
109+
110+
@ParameterizedTest
111+
@EnumSource(value = Material.class, names = {"GLASS", "GLASS_PANE", "RED_STAINED_GLASS", "BLUE_STAINED_GLASS_PANE", "TINTED_GLASS"})
112+
void greenhousesAllowedUnlessDisabled(Material glass) {
113+
roof(glass, 70);
114+
assertTrue(CropPlantingRule.hasOpenSky(location));
115+
YamlConfiguration config = new YamlConfiguration();
116+
config.set("allow-glass-roofs", false);
117+
CropPlantingRule.configure(config);
118+
assertFalse(CropPlantingRule.hasOpenSky(location));
119+
}
120+
121+
@Test
122+
void configurableCoverExemptionsAndDisable() {
123+
YamlConfiguration config = new YamlConfiguration();
124+
config.set("allowed-cover", java.util.List.of("OAK_LEAVES"));
125+
config.set("exempt-vanilla", java.util.List.of("WHEAT"));
126+
config.set("exempt-custom", java.util.List.of("Tomato"));
127+
CropPlantingRule.configure(config);
128+
roof(Material.OAK_LEAVES, 70);
129+
assertTrue(CropPlantingRule.hasOpenSky(location));
130+
assertFalse(CropPlantingRule.requiresOpenSky(Material.WHEAT));
131+
assertTrue(CropPlantingRule.requiresOpenSky(Material.NETHER_WART));
132+
assertFalse(CropPlantingRule.customRequiresOpenSky("tomato"));
133+
assertTrue(CropPlantingRule.customRequiresOpenSky("yeast"));
134+
config.set("require-open-sky", false);
135+
CropPlantingRule.configure(config);
136+
assertFalse(CropPlantingRule.requiresOpenSky(Material.CARROTS));
137+
assertFalse(CropPlantingRule.customRequiresOpenSky("rice"));
138+
}
139+
}

0 commit comments

Comments
 (0)