diff --git a/Human-Tests.md b/Human-Tests.md index 2dfa16a..e67a2d1 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -31,6 +31,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter ### Generated blocks and geometry - Inspect a newly allocated home and adjoining roads. Check road width, level, material, clearance, plot floor, support blocks, and optional sign against the active config. +- Generate one plot in a disposable world and confirm each adjoining road is fully paved to the configured width. Generate a plot directly beside it: confirm already-correct shared road blocks are untouched, any missing half-width legacy section is filled, and stair treads/openings remain intact. - Enable avenue stairs with a stair width narrower than the avenue. Generate a plot on a level below `plot.maxLevels`; verify stairs are added while the upper road is built and the lower road stays unchanged. Confirm the first tread starts one block above and forward from the road-level landing, connected following treads use inverted stairs underneath, and the top tread reaches the upper road Y. Check that exactly three road blocks before the top tread remain open only across the stair width, with regular road beside the opening. Repeat with a higher-level plot to confirm the lower stair flight remains open. - Test generation over varied terrain, including water, caves, uneven ground, and existing structures. Confirm generated floors and roads do not leave unsafe gaps or erase blocks outside their intended area. - Enable and disable optional floor, support, sign, spawn, world-spawn, and perimeter-wall settings one at a time on a disposable world. Confirm each setting changes only its intended behavior. @@ -73,10 +74,12 @@ Use this checklist for behavior that depends on a real Paper world, player inter ## Existing Automated Coverage -Run `./gradlew test` for the server-free JUnit suite. It has 34 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 40 tests covering: - `AvenueStairwayLayoutTest`: centered flight geometry, one-block offset from the lower road, top-tread alignment with the upper road, inverted backing plan, exact-width upper-road opening, and invalid-fit rejection. - `AvenueStairwayBuilderTest`: configured stair material and facing/half data, upside-down backing stairs, exact upper-road AIR placements, and no block writes at lower-road Y. +- `RoadLayoutTest`: full-width surrounding road bands, non-overlapping corners, and disabled roads at zero width. +- `RoadBlockWriterTest`: no-op writes for existing roads, filling missing road blocks, and preserving stair treads. - `CuboidTest`: WorldEdit bound conversion, vector ordering/null handling, touching intersections, and point containment. - `CurrentHomesStoreTest`: UUID-based persistence and resolving a legacy player-name key. - `PlayerJoinListenerTest`: the welcome bounds message when a home is supplied by a mock. diff --git a/README.md b/README.md index 983a656..b4031fb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Enable `road.avenues.stairs.generate` to add a centered stair run as part of the The breaking multi-level schema is documented in the example config at `src/main/resources/examples/multi-level.yml`. It uses split road sections (`road.streets.*` and `road.avenues.*`), a level-aware `plot.maxLevels` field, and the valid stair material `COBBLESTONE_STAIRS`. +Roads are generated at their full configured width when a plot is created. Generating an adjacent plot does not rewrite road blocks that are already correct; it only fills missing blocks, including gaps left by older half-width generation. Existing stair treads and upper-road openings are preserved. + Use `permissionOverrides` to assign plot size and home limits through any permission manager. Each permission node maps to a `priority`, `plotMultiplier`, and `maxPlots`; the matching node with the highest priority wins, and the last matching entry in YAML order wins ties. An explicit username entry in `userOverrides` takes precedence over permission rules. If no override matches, the global `plot.*` defaults apply. Permission rules are checked for online players; offline home generation uses username overrides or global defaults. For example: ```yaml diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 9e563bf..17c3acd 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -22,7 +22,9 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import com.majinnaibu.bukkitplugins.metropolis.commands.MetropolisCommand; @@ -727,7 +729,7 @@ public class MetropolisPlugin extends JavaPlugin { } } - private void createRoads(Cuboid plotCuboid, int roadMask) { + private void createRoads(Cuboid plotCuboid) { if (plotCuboid == null) { if (DEBUG) { getLogger().warning("plotCuboid is null"); @@ -736,84 +738,11 @@ public class MetropolisPlugin extends JavaPlugin { } if (roadWidth > 0) { - int x = 0; int y = plotCuboid.minY; - int z = 0; + Set stairOpenings = getUpperRoadOpenings(plotCuboid, y); - List stairOpenings = getUpperRoadOpenings(plotCuboid, y); - int roadWidth1 = roadWidth / 2; - int roadWidth2 = roadWidth - roadWidth1; - - // North West Corner - if ((roadMask & (ROAD_NORTH | ROAD_WEST)) != 0) { - for (x = plotCuboid.minX - roadWidth1; x < plotCuboid.minX; x++) { - for (z = plotCuboid.minZ - roadWidth1; z < plotCuboid.minZ; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // North Strip - if ((roadMask & ROAD_NORTH) != 0) { - for (x = plotCuboid.minX; x <= plotCuboid.maxX; x++) { - for (z = plotCuboid.minZ - roadWidth1; z < plotCuboid.minZ; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // North East Corner - if ((roadMask & (ROAD_NORTH | ROAD_EAST)) != 0) { - for (x = plotCuboid.maxX + 1; x <= plotCuboid.maxX + roadWidth2; x++) { - for (z = plotCuboid.minZ - roadWidth1; z < plotCuboid.minZ; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // East Strip - if ((roadMask & ROAD_EAST) != 0) { - for (x = plotCuboid.maxX + 1; x <= plotCuboid.maxX + roadWidth2; x++) { - for (z = plotCuboid.minZ; z <= plotCuboid.maxZ; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // South East Corner - if ((roadMask & (ROAD_SOUTH | ROAD_EAST)) != 0) { - for (x = plotCuboid.maxX + 1; x <= plotCuboid.maxX + roadWidth2; x++) { - for (z = plotCuboid.maxZ + 1; z <= plotCuboid.maxZ + roadWidth2; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // South Strip - if ((roadMask & ROAD_SOUTH) != 0) { - for (x = plotCuboid.minX; x <= plotCuboid.maxX; x++) { - for (z = plotCuboid.maxZ + 1; z <= plotCuboid.maxZ + roadWidth2; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // South West Corner - if ((roadMask & (ROAD_SOUTH | ROAD_WEST)) != 0) { - for (x = plotCuboid.minX - roadWidth1; x < plotCuboid.minX; x++) { - for (z = plotCuboid.maxZ + 1; z <= plotCuboid.maxZ + roadWidth2; z++) { - setRoad(x, y, z, stairOpenings); - } - } - } - - // West Strip - if ((roadMask & ROAD_WEST) != 0) { - for (x = plotCuboid.minX - roadWidth1; x < plotCuboid.minX; x++) { - for (z = plotCuboid.minZ; z <= plotCuboid.maxZ; z++) { - setRoad(x, y, z, stairOpenings); - } - } + for (RoadLayout.BlockPosition roadBlock : RoadLayout.surroundingRoad(plotCuboid, roadWidth)) { + setRoad(roadBlock.x(), y, roadBlock.z(), stairOpenings); } int upperLevel = Math.floorDiv(y - roadLevel, getLevelPitchY()); @@ -823,34 +752,13 @@ public class MetropolisPlugin extends JavaPlugin { } } - private void createRoads(Cuboid plotCuboid) { - createRoads(plotCuboid, ROAD_NORTH | ROAD_SOUTH | ROAD_EAST | ROAD_WEST); - } - - private void setRoad(int x, int y, int z, List stairOpenings) { + private void setRoad(int x, int y, int z, Set stairOpenings) { if (stairOpenings.contains(new AvenueStairwayLayout.Step(x, y, z))) { return; } - // if(DEBUG){getLogger().info(String.format("setting road at (%d, %d, %d)", x, y, z));} - - Block block = world.getBlockAt(x, y, z); - // Set the road block - block.setType(roadMaterial); - - // Set the support - if (generateRoadSupports && isPhysicsMaterial(block.getType())) { - Block blockUnder = world.getBlockAt(x, y - 1, z); - if (!isSolidMaterial(blockUnder.getType())) { - blockUnder.setType(roadSupportMaterial); - } - } - - // Clear the space above - for (int y1 = 0; y1 < spaceAboveRoad; y1++) { - block = world.getBlockAt(x, y + y1 + 1, z); - block.setType(Material.AIR); - } + RoadBlockWriter.build( + world, x, y, z, roadMaterial, roadSupportMaterial, generateRoadSupports, spaceAboveRoad); } private boolean isSolidMaterial(Material material) { @@ -1265,7 +1173,21 @@ public class MetropolisPlugin extends JavaPlugin { AvenueStairwayBuilder.build(world, flight, avenueStairMaterial); } - private List getUpperRoadOpenings(Cuboid plotCuboid, int roadY) { + private Set getUpperRoadOpenings(Cuboid plotCuboid, int roadY) { + Set openings = new HashSet<>(); + openings.addAll(getUpperRoadOpeningsForPlot(plotCuboid, roadY)); + if (_occupiedPlots != null) { + for (Plot plot : _occupiedPlots) { + if (plot instanceof PlayerHome home && home.getCuboid().minY == roadY) { + openings.addAll(getUpperRoadOpeningsForPlot(home.getCuboid(), roadY)); + } + } + } + return openings; + } + + private List getUpperRoadOpeningsForPlot( + Cuboid plotCuboid, int roadY) { if (!generateAvenueStairs || roadY <= roadLevel) { return List.of(); } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriter.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriter.java new file mode 100644 index 0000000..09b67da --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriter.java @@ -0,0 +1,66 @@ +/* +This file is part of Metropolis. + +Metropolis is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Metropolis is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Metropolis. If not, see . +*/ + +package com.majinnaibu.bukkitplugins.metropolis; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.type.Stairs; + +public final class RoadBlockWriter { + private RoadBlockWriter() {} + + public static void build( + World world, + int x, + int y, + int z, + Material roadMaterial, + Material supportMaterial, + boolean generateSupports, + int clearSpaceAbove) { + Block roadBlock = world.getBlockAt(x, y, z); + if (roadBlock.getType() == roadMaterial || roadBlock.getBlockData() instanceof Stairs) { + return; + } + + roadBlock.setType(roadMaterial); + if (generateSupports && (roadMaterial == Material.GRAVEL || roadMaterial == Material.SAND)) { + Block blockUnder = world.getBlockAt(x, y - 1, z); + if (!isSolid(blockUnder.getType())) { + blockUnder.setType(supportMaterial); + } + } + + for (int clearance = 1; clearance <= clearSpaceAbove; clearance++) { + Block blockAbove = world.getBlockAt(x, y + clearance, z); + if (blockAbove.getType() != Material.AIR) { + blockAbove.setType(Material.AIR); + } + } + } + + private static boolean isSolid(Material material) { + return material.isBlock() + && material != Material.AIR + && material != Material.WATER + && material != Material.LAVA + && material != Material.TORCH + && material != Material.REDSTONE_TORCH; + } +} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java new file mode 100644 index 0000000..2b4f603 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java @@ -0,0 +1,57 @@ +/* +This file is part of Metropolis. + +Metropolis is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Metropolis is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Metropolis. If not, see . +*/ + +package com.majinnaibu.bukkitplugins.metropolis; + +import java.util.ArrayList; +import java.util.List; + +public final class RoadLayout { + private RoadLayout() {} + + public static List surroundingRoad(Cuboid plot, int roadWidth) { + if (roadWidth < 1) { + return List.of(); + } + + List blocks = new ArrayList<>(); + addRectangle(blocks, plot.minX, plot.maxX, plot.minZ - roadWidth, plot.minZ - 1); + addRectangle(blocks, plot.minX, plot.maxX, plot.maxZ + 1, plot.maxZ + roadWidth); + addRectangle(blocks, plot.minX - roadWidth, plot.minX - 1, plot.minZ, plot.maxZ); + addRectangle(blocks, plot.maxX + 1, plot.maxX + roadWidth, plot.minZ, plot.maxZ); + addRectangle( + blocks, plot.minX - roadWidth, plot.minX - 1, plot.minZ - roadWidth, plot.minZ - 1); + addRectangle( + blocks, plot.maxX + 1, plot.maxX + roadWidth, plot.minZ - roadWidth, plot.minZ - 1); + addRectangle( + blocks, plot.minX - roadWidth, plot.minX - 1, plot.maxZ + 1, plot.maxZ + roadWidth); + addRectangle( + blocks, plot.maxX + 1, plot.maxX + roadWidth, plot.maxZ + 1, plot.maxZ + roadWidth); + return List.copyOf(blocks); + } + + private static void addRectangle( + List blocks, int minX, int maxX, int minZ, int maxZ) { + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + blocks.add(new BlockPosition(x, z)); + } + } + } + + public record BlockPosition(int x, int z) {} +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriterTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriterTest.java new file mode 100644 index 0000000..2f9b371 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriterTest.java @@ -0,0 +1,63 @@ +/* +This file is part of Metropolis. + +Metropolis is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Metropolis is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Metropolis. If not, see . +*/ + +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.mockito.ArgumentMatchers.any; +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 org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.type.Stairs; +import org.junit.jupiter.api.Test; + +class RoadBlockWriterTest { + @Test + void doesNotRewriteAnExistingRoadBlockOrItsClearance() { + World world = mock(World.class); + Block roadBlock = mock(Block.class); + when(world.getBlockAt(4, 62, 8)).thenReturn(roadBlock); + when(roadBlock.getType()).thenReturn(Material.COBBLESTONE); + + RoadBlockWriter.build(world, 4, 62, 8, Material.COBBLESTONE, Material.STONE, true, 4); + + verify(roadBlock, never()).setType(any(Material.class)); + verify(world, never()).getBlockAt(4, 63, 8); + } + + @Test + void fillsMissingRoadBlocksAndPreservesExistingStairTreads() { + World world = mock(World.class); + Block missingRoadBlock = mock(Block.class); + Block stairBlock = mock(Block.class); + when(world.getBlockAt(4, 62, 8)).thenReturn(missingRoadBlock); + when(world.getBlockAt(5, 76, 12)).thenReturn(stairBlock); + when(missingRoadBlock.getType()).thenReturn(Material.AIR); + when(stairBlock.getType()).thenReturn(Material.COBBLESTONE_STAIRS); + when(stairBlock.getBlockData()).thenReturn(mock(Stairs.class)); + + RoadBlockWriter.build(world, 4, 62, 8, Material.COBBLESTONE, Material.STONE, false, 0); + RoadBlockWriter.build(world, 5, 76, 12, Material.COBBLESTONE, Material.STONE, false, 4); + + verify(missingRoadBlock).setType(Material.COBBLESTONE); + verify(stairBlock, never()).setType(any(Material.class)); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java new file mode 100644 index 0000000..c2cff2f --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java @@ -0,0 +1,48 @@ +/* +This file is part of Metropolis. + +Metropolis is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Metropolis is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Metropolis. If not, see . +*/ + +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class RoadLayoutTest { + private static final Cuboid PLOT = new Cuboid(2, 62, 2, 33, 71, 33); + + @Test + void buildsFullRoadWidthAndNonOverlappingCornersAtOnce() { + List road = RoadLayout.surroundingRoad(PLOT, 4); + HashSet uniqueBlocks = new HashSet<>(road); + + assertEquals(576, road.size()); + assertEquals(road.size(), uniqueBlocks.size()); + assertTrue(road.contains(new RoadLayout.BlockPosition(34, 2))); + assertTrue(road.contains(new RoadLayout.BlockPosition(37, 33))); + assertTrue(road.contains(new RoadLayout.BlockPosition(2, 34))); + assertTrue(road.contains(new RoadLayout.BlockPosition(33, 37))); + } + + @Test + void returnsNoRoadForZeroWidth() { + assertTrue(RoadLayout.surroundingRoad(PLOT, 0).isEmpty()); + } +}