Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ Java 21 is required. The output JAR goes to `target/`.
**Key components:**
- `CaveBlock.java` — Main addon lifecycle (`onLoad`, `onEnable`, `createWorlds`). Creates 3 world generators (normal, nether, end) and registers flags/listeners.
- `Settings.java` — 50+ annotated config fields implementing BentoBox's `WorldSettings`. Maps to `config.yml`.
- `generators/ChunkGeneratorWorld.java` — Core chunk generator. Fills the world with the dimension's base blocks (stone/netherrack/end stone) and relies on the current populator pipeline for additional material generation.
- `generators/populators/` — `NewMaterialPopulator` (current vanilla-like material generation) and `FlatBiomeProvider` (biome distribution).
- `generators/ChunkGeneratorWorld.java` — Core chunk generator. Fills the world with the dimension's base blocks (stone/netherrack/end stone) and delegates additional material generation to the populator pipeline. `NoiseCaveGenerator` and `Ore` support noise-based cave carving and ore placement.
- `generators/populators/` — `NewMaterialPopulator` (vanilla-like material generation), `CaveDecorationPopulator` (cave decoration), and the biome providers `FlatBiomeProvider` / `NetherBiomeProvider` (biome distribution per dimension).
- `listeners/CustomHeightLimitations.java` — Prevents players from going above the configured world depth. Respects `SKY_WALKER_FLAG` and creative/op bypass.
- `listeners/StructureGenerationListener.java` — Suppresses vanilla structures the admin disabled in `world.structures` from the CaveBlock overworld: cancels their spawn (`AsyncStructureSpawnEvent`) and removes them from structure searches (`StructuresLocateEvent`) so `/locate`, explorer maps, etc. don't scan to the radius cap and freeze the server.

**BentoBox framework patterns to follow:**
- Addons use `GameModeAddon` lifecycle, not Bukkit's `JavaPlugin` directly
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<!-- Revision variable removes warning about dynamic version -->
<revision>${build.version}-SNAPSHOT</revision>
<!-- This allows to change between versions and snapshots. -->
<build.version>1.23.0</build.version>
<build.version>1.23.1</build.version>
<build.number>-LOCAL</build.number>
<sonar.organization>bentobox-world</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/world/bentobox/caveblock/CaveBlock.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public void onEnable()

// Register listeners
this.registerListener(new CustomHeightLimitations(this));
this.registerListener(new StructureGenerationListener(this));
// StructureGenerationListener is registered earlier, in createWorlds(), so it is
// active before the first chunks (including the spawn area) are generated.
}


Expand Down Expand Up @@ -138,6 +139,11 @@ public void allLoaded() {
@Override
public void createWorlds()
{
// Register the structure listener before any world is created. createWorlds() runs
// before onEnable(), and generating a world also generates its spawn-area chunks, so a
// listener registered in onEnable() would miss those first structures (issue #116).
this.registerListener(new StructureGenerationListener(this));

String worldName = this.settings.getWorldName().toLowerCase();

if (this.getServer().getWorld(worldName) == null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package world.bentobox.caveblock.listeners;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;

import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.world.AsyncStructureSpawnEvent;
import org.bukkit.generator.structure.Structure;

import io.papermc.paper.event.world.StructuresLocateEvent;
import world.bentobox.caveblock.CaveBlock;

/**
Expand All @@ -23,10 +28,17 @@
* disabled in {@code world.structures}.</p>
*
* <p>{@link AsyncStructureSpawnEvent} fires off the main thread during chunk
* generation, so this handler only reads config and inspects the event. It
* generation, so that handler only reads config and inspects the event. It
* queries the event's world for environment and ownership checks but performs
* no world or block mutation, which is what makes it safe to run async.</p>
*
* <p>Cancelling the spawn stops a structure being <em>placed</em> but leaves its
* placement rules intact, so structure <em>searches</em> ({@code /locate}, Eyes of
* Ender, treasure/explorer maps, villager map trades) keep proposing candidate
* positions that are then all cancelled, scanning to the radius cap and freezing
* the server (issue #116). {@link #onStructuresLocate} closes that hole by removing
* disabled structures from the search before it runs.</p>
*
* @author tastybento
*/
public class StructureGenerationListener implements Listener {
Expand All @@ -47,9 +59,8 @@ public StructureGenerationListener(CaveBlock addon) {
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onStructureSpawn(AsyncStructureSpawnEvent event) {
World world = event.getWorld();
// Only the CaveBlock overworld uses vanilla structures; nether/end do not.
if (world.getEnvironment() != World.Environment.NORMAL || !addon.inWorld(world)) {
if (!isCaveOverworld(event.getWorld())) {
return;
}
String structureKey = event.getStructure().getKey().getKey();
Expand All @@ -58,6 +69,58 @@ public void onStructureSpawn(AsyncStructureSpawnEvent event) {
}
}

/**
* Prevents disabled structures from being <em>located</em>. {@link StructuresLocateEvent}
* fires before any structure search — the {@code /locate} command, Eyes of Ender,
* explorer/treasure maps, dolphins and villager map trades. On a cave world where the
* target structure is suppressed, that search never succeeds and scans out to the radius
* cap, freezing the main thread (issue #116). Removing the disabled structures from the
* search list — and cancelling outright when nothing remains — skips the scan entirely so
* the search returns "not found" instantly.
*
* @param event the structure locate event
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onStructuresLocate(StructuresLocateEvent event) {
if (!isCaveOverworld(event.getWorld())) {
return;
}
List<Structure> targets = event.getStructures();
// Collect into a mutable list: StructuresLocateEvent#setStructures expects one
// that Paper (or a later listener) can still mutate. Stream#toList() is immutable.
List<Structure> allowed = targets.stream()
.filter(structure -> !isDisabled(structure.getKey().getKey()))
.collect(Collectors.toCollection(ArrayList::new));
if (allowed.size() == targets.size()) {
// Nothing disabled in this search — let it run normally.
return;
}
if (allowed.isEmpty()) {
// Every requested structure is disabled here: skip the expensive scan entirely.
event.setCancelled(true);
} else {
event.setStructures(allowed);
}
Comment thread
tastybento marked this conversation as resolved.
}

/**
* @param world a world
* @return {@code true} if {@code world} is the CaveBlock overworld.
*
* <p>Matches on the configured world name rather than {@link CaveBlock#inWorld(World)}
* because structures in the spawn area are generated during {@code createWorlds()},
* before the addon's island-world reference is assigned. At that point
* {@code inWorld()} would compare against a null world and fail, so the first
* structures would slip through (issue #116).</p>
*/
private boolean isCaveOverworld(World world) {
if (world.getEnvironment() != World.Environment.NORMAL) {
return false;
}
String configuredName = addon.getSettings().getWorldName();
return configuredName != null && world.getName().equalsIgnoreCase(configuredName);
}

/**
* @param structureKey the vanilla structure key path, e.g. {@code ancient_city}
* @return {@code true} if the config explicitly disables this structure
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package world.bentobox.caveblock.listeners;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.bukkit.NamespacedKey;
import org.bukkit.World;
Expand All @@ -17,9 +23,11 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockbukkit.mockbukkit.MockBukkit;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import io.papermc.paper.event.world.StructuresLocateEvent;
import world.bentobox.caveblock.CaveBlock;
import world.bentobox.caveblock.Settings;

Expand All @@ -44,7 +52,8 @@ public void setUp() {
lenient().when(addon.getSettings()).thenReturn(settings);
lenient().when(settings.getGenerateStructures())
.thenReturn(Map.of("ancient_city", false, "trial_chambers", false, "mineshaft", true));
lenient().when(addon.inWorld(world)).thenReturn(true);
lenient().when(settings.getWorldName()).thenReturn("caveblock_world");
lenient().when(world.getName()).thenReturn("caveblock_world");
lenient().when(world.getEnvironment()).thenReturn(World.Environment.NORMAL);
listener = new StructureGenerationListener(addon);
}
Expand Down Expand Up @@ -86,7 +95,7 @@ void testUnlistedStructureGeneratesNormally() {

@Test
void testStructureOutsideCaveBlockWorldIsIgnored() {
when(addon.inWorld(world)).thenReturn(false);
when(world.getName()).thenReturn("some_other_world");
AsyncStructureSpawnEvent e = event("ancient_city");
listener.onStructureSpawn(e);
verify(e, never()).setCancelled(true);
Expand All @@ -107,4 +116,55 @@ void testConfigKeyWithHyphensAndCaseMatches() {
listener.onStructureSpawn(e);
verify(e).setCancelled(true);
}

private StructuresLocateEvent locateEvent(String... structureKeys) {
// Collect into a mutable list to mirror Paper's StructuresLocateEvent#getStructures(),
// which is documented as mutable; Stream#toList() would return an unmodifiable list.
List<Structure> structures = Arrays.stream(structureKeys).map(key -> {
Structure structure = mock(Structure.class);
lenient().when(structure.getKey()).thenReturn(NamespacedKey.minecraft(key));
return structure;
}).collect(Collectors.toCollection(ArrayList::new));
StructuresLocateEvent e = mock(StructuresLocateEvent.class);
lenient().when(e.getWorld()).thenReturn(world);
lenient().when(e.getStructures()).thenReturn(structures);
return e;
}

@Test
void testLocateAllDisabledIsCancelled() {
StructuresLocateEvent e = locateEvent("trial_chambers");
listener.onStructuresLocate(e);
verify(e).setCancelled(true);
verify(e, never()).setStructures(anyList());
}

@Test
void testLocateMixedNarrowsToEnabledOnly() {
StructuresLocateEvent e = locateEvent("trial_chambers", "mineshaft");
listener.onStructuresLocate(e);
verify(e, never()).setCancelled(true);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Structure>> captor = ArgumentCaptor.forClass(List.class);
verify(e).setStructures(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals("mineshaft", captor.getValue().get(0).getKey().getKey());
}

@Test
void testLocateAllEnabledIsUntouched() {
StructuresLocateEvent e = locateEvent("mineshaft", "village_plains");
listener.onStructuresLocate(e);
verify(e, never()).setCancelled(true);
verify(e, never()).setStructures(anyList());
}

@Test
void testLocateOutsideCaveBlockWorldIsIgnored() {
when(world.getName()).thenReturn("some_other_world");
StructuresLocateEvent e = locateEvent("trial_chambers");
listener.onStructuresLocate(e);
verify(e, never()).setCancelled(true);
verify(e, never()).setStructures(anyList());
}
}
Loading