From f68d043d021f9a8b1bf4c0233605f213dc8dd32d Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 07:07:12 -0700 Subject: [PATCH 01/10] Continue debug test-home naming --- Human-Tests.md | 1 + README.md | 2 +- ...tropolisDebugGenerateTestHomesCommand.java | 110 +++++++++++++-- ...olisDebugGenerateTestHomesCommandTest.java | 129 ++++++++++++++++++ 4 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommandTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 055a782..ddeadad 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -85,6 +85,7 @@ Run `./gradlew test` for the server-free JUnit suite. It has 26 tests covering: - `MetropolisPlotGoCommandTest`: self and target teleportation, ambiguous memberships, absent membership, and unsafe-destination feedback. - `MetropolisPlotReserveCommandTest`: parsing six coordinates and rejecting malformed coordinates. - `MetropolisHomeAcquireTest`: acquisition below the limit and refusal at the limit. +- `MetropolisDebugGenerateTestHomesCommandTest`: asynchronous tick-queued generation, one home per interval, no overlapping batches, and continuing test-player names across repeated runs. The two opt-in tests in `src/integrationTest` require a running Paper server and RCON. They check that Metropolis appears in `/plugins` and that a coordinate reservation is persisted by WorldGuard. They do not currently verify restart persistence, join allocation, WorldEdit-selection reservations, permissions, teleports, generated blocks, protection behavior, or the other command workflows above. Run them with the environment variables documented in `README.md`; use a disposable server because the reservation test changes its WorldGuard data. diff --git a/README.md b/README.md index 4ffa5ae..5346207 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Assign these permission nodes to groups or individual users in your permission p | `/metropolis-plot-reserve ` | `metropolis.plot.reserve` | Creates a named reservation from explicit bounds. A player can instead run `/metropolis-plot-reserve ` with a WorldEdit selection. | | `/metropolis-plot-go [playerName]` | Reservation owner/member; `metropolis.plot.go` to target others or bypass membership | Teleports the sender or an online target to a reservation they own or belong to. If they have several, specify one with `/metropolis-plot-go [playerName]`. | | `/metropolis-flag-reset` | `metropolis.flag.reset` | Reapplies Metropolis protection flags to the City and home regions. | -| `/metropolis-debug-generatetesthomes ` | `metropolis.debug` | Generates test homes; intended for development servers. | +| `/metropolis-debug-generatetesthomes ` | `metropolis.debug` | Queues test-home generation on the main thread, creating one home every two ticks; intended for development servers. | ## License diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommand.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommand.java index d89c90f..deafb94 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommand.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommand.java @@ -17,15 +17,22 @@ along with Metropolis. If not, see . package com.majinnaibu.bukkitplugins.metropolis.commands; -import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; +import java.util.logging.Level; +import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; +import com.majinnaibu.bukkitplugins.metropolis.PlayerHome; + +import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.scheduler.BukkitTask; public class MetropolisDebugGenerateTestHomesCommand implements CommandExecutor { + private static final long TICKS_BETWEEN_HOMES = 2L; - private MetropolisPlugin _plugin; + private final MetropolisPlugin _plugin; + private BukkitTask _activeTask; public MetropolisDebugGenerateTestHomesCommand(MetropolisPlugin plugin) { _plugin = plugin; @@ -33,15 +40,102 @@ public class MetropolisDebugGenerateTestHomesCommand implements CommandExecutor @Override public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (args.length != 1) { + sender.sendMessage("Usage: /metropolis-debug-generatetesthomes "); + return false; + } + + int numHomes; try { - int numHomes = Integer.parseInt(args[0]); - for (int i = 1; i <= numHomes; i++) { - _plugin.generateHome(String.format("test%d", i)); + numHomes = Integer.parseInt(args[0]); + } catch (NumberFormatException ex) { + sender.sendMessage("Usage: /metropolis-debug-generatetesthomes "); + return false; + } + + if (numHomes < 1) { + sender.sendMessage("Usage: /metropolis-debug-generatetesthomes "); + return false; + } + + if (_activeTask != null && !_activeTask.isCancelled()) { + sender.sendMessage("[Metropolis] Test-home generation is already in progress."); + return false; + } + + TestHomeBatch batch = new TestHomeBatch(sender, numHomes); + _activeTask = + _plugin.getServer().getScheduler().runTaskTimer(_plugin, batch, 1L, TICKS_BETWEEN_HOMES); + sender.sendMessage( + "[Metropolis] Queued " + + numHomes + + " test homes; generating one every " + + TICKS_BETWEEN_HOMES + + " ticks."); + return true; + } + + private final class TestHomeBatch implements Runnable { + private final CommandSender sender; + private final int requestedHomes; + private int generatedHomes; + private int nextTestNumber = 1; + private boolean finished; + + private TestHomeBatch(CommandSender sender, int requestedHomes) { + this.sender = sender; + this.requestedHomes = requestedHomes; + } + + @Override + public void run() { + if (finished) { + return; } - return true; - } catch (NumberFormatException ex) { - return false; + int testNumber = nextTestNumber++; + String testName = "Test" + testNumber; + try { + OfflinePlayer testPlayer = _plugin.getServer().getOfflinePlayer(testName); + if (_plugin.getNumPlots(testPlayer.getUniqueId()) > 0) { + return; + } + + PlayerHome home = _plugin.generateHome(testPlayer, 1); + if (home == null) { + sender.sendMessage( + "[Metropolis] Could not generate " + + testName + + "; completed " + + generatedHomes + + " of " + + requestedHomes + + " test homes."); + finish(); + return; + } + + generatedHomes++; + if (generatedHomes == requestedHomes) { + sender.sendMessage("[Metropolis] Generated " + generatedHomes + " test homes."); + finish(); + } + } catch (RuntimeException exception) { + _plugin + .getLogger() + .log(Level.SEVERE, "Failed to generate test home " + testName, exception); + sender.sendMessage( + "[Metropolis] Test-home generation failed after " + generatedHomes + " homes."); + finish(); + } + } + + private void finish() { + finished = true; + if (_activeTask != null) { + _activeTask.cancel(); + _activeTask = null; + } } } } diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommandTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommandTest.java new file mode 100644 index 0000000..4d7b013 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisDebugGenerateTestHomesCommandTest.java @@ -0,0 +1,129 @@ +/* +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.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; +import com.majinnaibu.bukkitplugins.metropolis.PlayerHome; + +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; + +class MetropolisDebugGenerateTestHomesCommandTest { + @Test + void repeatedRunsContinueWithTheNextUnusedTestName() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + Server server = mock(Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + Set existingHomeOwners = new HashSet<>(); + List generatedNames = new ArrayList<>(); + List scheduledBatches = new ArrayList<>(); + + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(scheduler); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(2L))) + .thenAnswer( + invocation -> { + scheduledBatches.add(invocation.getArgument(1)); + return mock(BukkitTask.class); + }); + when(server.getOfflinePlayer(anyString())) + .thenAnswer( + invocation -> { + String name = invocation.getArgument(0); + OfflinePlayer player = mock(OfflinePlayer.class); + UUID playerId = UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(playerId); + return player; + }); + when(plugin.getNumPlots(any(UUID.class))) + .thenAnswer(invocation -> existingHomeOwners.contains(invocation.getArgument(0)) ? 1 : 0); + when(plugin.generateHome(any(OfflinePlayer.class), eq(1))) + .thenAnswer( + invocation -> { + OfflinePlayer player = invocation.getArgument(0); + generatedNames.add(player.getName()); + existingHomeOwners.add(player.getUniqueId()); + return mock(PlayerHome.class); + }); + + MetropolisDebugGenerateTestHomesCommand executor = + new MetropolisDebugGenerateTestHomesCommand(plugin); + + assertTrue(executor.onCommand(sender, mock(Command.class), "testhomes", new String[] {"10"})); + assertTrue(generatedNames.isEmpty()); + assertEquals(1, scheduledBatches.size()); + assertFalse(executor.onCommand(sender, mock(Command.class), "testhomes", new String[] {"10"})); + + scheduledBatches.getFirst().run(); + assertEquals(List.of("Test1"), generatedNames); + for (int tick = 1; tick < 10; tick++) { + scheduledBatches.getFirst().run(); + } + assertEquals(10, generatedNames.size()); + + assertTrue(executor.onCommand(sender, mock(Command.class), "testhomes", new String[] {"10"})); + assertEquals(2, scheduledBatches.size()); + for (int tick = 0; tick < 20; tick++) { + scheduledBatches.get(1).run(); + } + + List expectedNames = new ArrayList<>(); + for (int testNumber = 1; testNumber <= 20; testNumber++) { + expectedNames.add("Test" + testNumber); + } + assertEquals(expectedNames, generatedNames); + } + + @Test + void rejectsNonPositiveCountsWithoutGeneratingHomes() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + MetropolisDebugGenerateTestHomesCommand executor = + new MetropolisDebugGenerateTestHomesCommand(plugin); + + assertFalse( + executor.onCommand( + mock(CommandSender.class), mock(Command.class), "testhomes", new String[] {"0"})); + + verify(plugin, never()).generateHome(any(OfflinePlayer.class), eq(1)); + } +} From 04a0c1d95951499dbd32f396b9910f07de3f1c5d Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 07:04:09 -0700 Subject: [PATCH 02/10] Add multi-level plot allocation support --- Human-Tests.md | 3 +- README.md | 5 +- .../metropolis/MetropolisPlugin.java | 295 ++++++++++++------ src/main/resources/config.yml | 23 +- src/main/resources/examples/multi-level.yml | 59 ++++ .../commands/MetropolisHomeAcquireTest.java | 9 + 6 files changed, 292 insertions(+), 102 deletions(-) create mode 100644 src/main/resources/examples/multi-level.yml diff --git a/Human-Tests.md b/Human-Tests.md index ddeadad..3398b48 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -23,6 +23,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Join as a new player. Confirm one home is allocated and the welcome message reports its actual bounds. Check the corresponding `h_1_` region has the player as owner and its bounds match the configured plot size. - Set `plot.initial: 0` on a disposable server, restart, and join as a fresh player. Confirm no home is created until the player runs `/metropolis-home-acquire`; then verify the command creates the initial home and makes it active. - Join with a second new player. Confirm the new home is distinct, does not overlap the first home or spawn, and the City region grows to include it. +- Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and stacked plots use the same X/Z allocation pattern at each level. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. - Test a user override that grants a different plot multiplier or home limit. Confirm the generated bounds and allowed number of homes match the override, and that the configured global defaults still apply to other players. @@ -71,7 +72,7 @@ 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 26 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 28 tests covering: - `CuboidTest`: WorldEdit bound conversion, vector ordering/null handling, touching intersections, and point containment. - `CurrentHomesStoreTest`: UUID-based persistence and resolving a legacy player-name key. diff --git a/README.md b/README.md index 5346207..ace68fe 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Metropolis expands a protected Minecraft city as players join. Each player can r ## Features - Assigns a protected home plot when a player joins by default and supports acquiring additional homes up to the configured player limit; set `plot.initial` to `0` to require `/metropolis-home-acquire` for the first home. +- Adds stacked plot levels with `plot.maxLevels`, while `plot.sizeY` continues to define the full height of each individual plot. - Supports per-player plot sizes and home limits through `userOverrides` or configurable permission tiers; acquired homes can be selected as the active home. - Expands the City region as plots are occupied, with optional spawn generation, world-spawn placement, floors, support blocks, signs, and perimeter walls. - Generates roads around plots with configurable width, level, material, clearance, and supports. @@ -57,7 +58,9 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p ## Configuration -`plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. +`plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. The per-level stack is configured with `plot.sizeY` and `plot.maxLevels`: `sizeY` is the full height of one plot level, and `maxLevels` is the number of stacked levels that can be allocated without exceeding the world build height. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. + +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`. 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: diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 9d491c7..ccd1e67 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -93,10 +93,11 @@ public class MetropolisPlugin extends JavaPlugin { int size = 1; private int plotSizeX = 24; - // private int plotSizeY = 256; + private int plotSizeY = 256; private int plotSizeZ = 24; private int gridSizeX = 28; private int gridSizeY = 256; + private int maxLevels = 1; private int gridSizeZ = 28; private int roadWidth = 4; private int roadLevel = 62; @@ -178,19 +179,40 @@ public class MetropolisPlugin extends JavaPlugin { getLogger().info("Reading configuration from file."); } plotSizeX = safeGetIntFromConfig(config, "plot.sizeX"); + plotSizeY = optionalIntFromConfig(config, "plot.sizeY", 256); plotSizeZ = safeGetIntFromConfig(config, "plot.sizeZ"); + maxLevels = optionalIntFromConfig(config, "plot.maxLevels", "maxLevels", 1); generateFloor = safeGetBooleanFromConfig(config, "plot.floor.generate"); floorMaterial = safeGetMaterialFromConfig(config, "plot.floor.material"); spaceAboveFloor = safeGetIntFromConfig(config, "plot.floor.clearSpaceAbove"); generateFloorSupports = safeGetBooleanFromConfig(config, "plot.floor.supports.generate"); floorSupportMaterial = safeGetMaterialFromConfig(config, "plot.floor.supports.material"); generateSign = safeGetBooleanFromConfig(config, "plot.sign.generate"); - roadWidth = safeGetIntFromConfig(config, "road.width"); - spaceAboveRoad = safeGetIntFromConfig(config, "road.clearSpaceAbove"); - roadLevel = safeGetIntFromConfig(config, "road.level"); - roadMaterial = safeGetMaterialFromConfig(config, "road.material"); - generateRoadSupports = safeGetBooleanFromConfig(config, "road.supports.generate"); - roadSupportMaterial = safeGetMaterialFromConfig(config, "road.supports.material"); + roadWidth = + safeGetIntFromConfig(config, "road.streets.width", "road.avenues.width", "road.width"); + spaceAboveRoad = + safeGetIntFromConfig( + config, + "road.streets.clearSpaceAbove", + "road.avenues.clearSpaceAbove", + "road.clearSpaceAbove"); + roadLevel = + safeGetIntFromConfig(config, "road.streets.level", "road.avenues.level", "road.level"); + roadMaterial = + safeGetMaterialFromConfig( + config, "road.streets.material", "road.avenues.material", "road.material"); + generateRoadSupports = + safeGetBooleanFromConfig( + config, + "road.streets.supports.generate", + "road.avenues.supports.generate", + "road.supports.generate"); + roadSupportMaterial = + safeGetMaterialFromConfig( + config, + "road.streets.supports.material", + "road.avenues.supports.material", + "road.supports.material"); generateSpawn = safeGetBooleanFromConfig(config, "spawn.generate"); setWorldSpawn = safeGetBooleanFromConfig(config, "spawn.setAsWorldSpawn"); spawnFloorMaterial = safeGetMaterialFromConfig(config, "spawn.material"); @@ -243,7 +265,7 @@ public class MetropolisPlugin extends JavaPlugin { } gridSizeX = plotSizeX + roadWidth; - gridSizeY = world.getMaxHeight(); + gridSizeY = Math.max(plotSizeY + roadWidth, 1); gridSizeZ = plotSizeZ + roadWidth; regionManager = @@ -415,43 +437,72 @@ public class MetropolisPlugin extends JavaPlugin { } } - private boolean safeGetBooleanFromConfig(Configuration config, String name) { - if (config.isBoolean(name)) { - return config.getBoolean(name); - } else { - throwInvalidConfigException(); - return false; + private boolean safeGetBooleanFromConfig(Configuration config, String... names) { + for (String name : names) { + if (config.isBoolean(name)) { + return config.getBoolean(name); + } } + throwInvalidConfigException(); + return false; } - private int safeGetIntFromConfig(Configuration config, String name) { - if (config.isInt(name)) { - return config.getInt(name); - } else { - throwInvalidConfigException(); - return 0; + private int safeGetIntFromConfig(Configuration config, String... names) { + for (String name : names) { + if (config.isInt(name)) { + return config.getInt(name); + } } + throwInvalidConfigException(); + return 0; } - private Material safeGetMaterialFromConfig(Configuration config, String name) { - Material material = null; - if (config.isInt(name)) { - material = - switch (config.getInt(name)) { - case 1 -> Material.STONE; - case 2 -> Material.GRASS_BLOCK; - case 4 -> Material.COBBLESTONE; - case 7 -> Material.BEDROCK; - default -> null; - }; - } else if (config.isString(name)) { - material = Material.matchMaterial(config.getString(name)); + private int optionalIntFromConfig(Configuration config, String[] names, int defaultValue) { + for (String name : names) { + if (config.isInt(name)) { + return config.getInt(name); + } } - if (material == null) { - getLogger().severe("Invalid material configured at " + name); - throwInvalidConfigException(); + return defaultValue; + } + + private int optionalIntFromConfig(Configuration config, String name, int defaultValue) { + return config.isInt(name) ? config.getInt(name) : defaultValue; + } + + private int optionalIntFromConfig( + Configuration config, String firstName, String secondName, int defaultValue) { + if (config.isInt(firstName)) { + return config.getInt(firstName); } - return material; + if (config.isInt(secondName)) { + return config.getInt(secondName); + } + return defaultValue; + } + + private Material safeGetMaterialFromConfig(Configuration config, String... names) { + for (String name : names) { + Material material = null; + if (config.isInt(name)) { + material = + switch (config.getInt(name)) { + case 1 -> Material.STONE; + case 2 -> Material.GRASS_BLOCK; + case 4 -> Material.COBBLESTONE; + case 7 -> Material.BEDROCK; + default -> null; + }; + } else if (config.isString(name)) { + material = Material.matchMaterial(config.getString(name)); + } + if (material != null) { + return material; + } + } + getLogger().severe("Invalid material configured at " + String.join(" or ", names)); + throwInvalidConfigException(); + return Material.STONE; } private void throwInvalidConfigException() { @@ -577,6 +628,16 @@ public class MetropolisPlugin extends JavaPlugin { return Math.max(0, count); } + public static int normalizeMaxLevels(int count) { + return Math.max(1, count); + } + + public static int clampMaxLevels(int requestedLevels, int availableLevels) { + int normalizedRequested = normalizeMaxLevels(requestedLevels); + int normalizedAvailable = Math.max(1, availableLevels); + return Math.min(normalizedRequested, normalizedAvailable); + } + public static boolean shouldAutoGenerateInitialHome(int count) { return count > 0; } @@ -595,7 +656,7 @@ public class MetropolisPlugin extends JavaPlugin { private void generateFloor(Cuboid plotCuboid) { int x = 0; - int y = roadLevel; + int y = plotCuboid.minY; int z = 0; for (x = plotCuboid.minX; x <= plotCuboid.maxX; x++) { @@ -636,7 +697,7 @@ public class MetropolisPlugin extends JavaPlugin { private void createRoads(Cuboid plotCuboid, int roadMask) { if (roadWidth > 0) { int x = 0; - int y = roadLevel; + int y = plotCuboid.minY; int z = 0; if (plotCuboid == null) { @@ -763,7 +824,14 @@ public class MetropolisPlugin extends JavaPlugin { } public boolean isBlockOccupied(int row, int col) { - Cuboid cuboid = new Cuboid(getGridMin(row, col, 1), getGridMax(row, col, 1)); + return isBlockOccupied(row, col, 0, 1); + } + + public boolean isBlockOccupied(int row, int col, int level, int plotMultiplier) { + Cuboid cuboid = + new Cuboid( + getGridMin(row, col, plotMultiplier, level), + getGridMax(row, col, plotMultiplier, level)); for (Plot plot : _occupiedPlots) { if (plot.getCuboid().intersects(cuboid)) { return true; @@ -778,9 +846,13 @@ public class MetropolisPlugin extends JavaPlugin { } private boolean areBlocksOccupied(int row, int col, int i) { + return areBlocksOccupied(row, col, i, 0); + } + + private boolean areBlocksOccupied(int row, int col, int i, int level) { for (int ix = col; ix < col + i; ix++) { for (int iy = row; iy < row + i; iy++) { - if (isBlockOccupied(iy, ix)) { + if (isBlockOccupied(iy, ix, level, 1)) { return true; } } @@ -796,56 +868,63 @@ public class MetropolisPlugin extends JavaPlugin { int min = -ring; int max = ring - (plotMultiplier - 1); boolean done = false; + int levelCount = getEffectiveMaxLevels(); while (!done) { row = min; col = min; - // Top - for (col = min; col <= max; col++) { - if (!areBlocksOccupied(row, col, plotMultiplier)) { - if (DEBUG) { - getLogger().info(String.format("row: %d, col: %d", row, col)); - } - return new Cuboid( - getPlotMin(row, col, plotMultiplier), getPlotMax(row, col, plotMultiplier)); - } - } - - // Right side - col = max; - for (row = min + 1; row < max; row++) { - if (!areBlocksOccupied(row, col, plotMultiplier)) { - if (DEBUG) { - getLogger().info(String.format("row: %d, col: %d", row, col)); - } - return new Cuboid( - getPlotMin(row, col, plotMultiplier), getPlotMax(row, col, plotMultiplier)); - } - } - - // Bottom - row = max; - for (col = max; col >= min; col--) { - if (!areBlocksOccupied(row, col, plotMultiplier)) { - if (DEBUG) { - getLogger().info(String.format("row: %d, col: %d", row, col)); - } - return new Cuboid( - getPlotMin(row, col, plotMultiplier), getPlotMax(row, col, plotMultiplier)); - } - } - - // Left - col = min; - for (row = max; row > min; row--) { - if (!areBlocksOccupied(row, col, plotMultiplier)) { - if (row != 0 || col != 0) { + for (int level = 0; level < levelCount; level++) { + // Top + for (col = min; col <= max; col++) { + if (!areBlocksOccupied(row, col, plotMultiplier, level)) { if (DEBUG) { - getLogger().info(String.format("row: %d, col: %d", row, col)); + getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); } return new Cuboid( - getPlotMin(row, col, plotMultiplier), getPlotMax(row, col, plotMultiplier)); + getPlotMin(row, col, plotMultiplier, level), + getPlotMax(row, col, plotMultiplier, level)); + } + } + + // Right side + col = max; + for (row = min + 1; row < max; row++) { + if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (DEBUG) { + getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); + } + return new Cuboid( + getPlotMin(row, col, plotMultiplier, level), + getPlotMax(row, col, plotMultiplier, level)); + } + } + + // Bottom + row = max; + for (col = max; col >= min; col--) { + if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (DEBUG) { + getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); + } + return new Cuboid( + getPlotMin(row, col, plotMultiplier, level), + getPlotMax(row, col, plotMultiplier, level)); + } + } + + // Left + col = min; + for (row = max; row > min; row--) { + if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (row != 0 || col != 0) { + if (DEBUG) { + getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); + } + return new Cuboid( + getPlotMin(row, col, plotMultiplier, level), + getPlotMax(row, col, plotMultiplier, level)); + } } } } @@ -853,12 +932,16 @@ public class MetropolisPlugin extends JavaPlugin { ring++; min = -ring; max = ring - (plotMultiplier - 1); + if (ring > 256) { + done = true; + } } if (DEBUG) { getLogger().info(String.format("row: %d, col: %d", row, col)); } - return new Cuboid(getPlotMin(row, col, plotMultiplier), getPlotMax(row, col, plotMultiplier)); + return new Cuboid( + getPlotMin(row, col, plotMultiplier, 0), getPlotMax(row, col, plotMultiplier, 0)); } private void resizeCityRegion() { @@ -902,41 +985,54 @@ public class MetropolisPlugin extends JavaPlugin { } public BlockVector3 getPlotMin(int row, int col, int plotMultiplier) { - BlockVector3 gridMin = getGridMin(row, col, plotMultiplier); + return getPlotMin(row, col, plotMultiplier, 0); + } + + public BlockVector3 getPlotMin(int row, int col, int plotMultiplier, int level) { + BlockVector3 gridMin = getGridMin(row, col, plotMultiplier, level); BlockVector3 bv = - BlockVector3.at(gridMin.x() + roadWidth / 2, gridMin.y(), gridMin.z() + roadWidth / 2); + BlockVector3.at( + gridMin.x() + roadWidth / 2, getLevelStartY(level), gridMin.z() + roadWidth / 2); getLogger().info(String.format("getPlotMin (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } public BlockVector3 getPlotMax(int row, int col, int plotMultiplier) { - BlockVector3 gridMax = getGridMax(row, col, plotMultiplier); + return getPlotMax(row, col, plotMultiplier, 0); + } + + public BlockVector3 getPlotMax(int row, int col, int plotMultiplier, int level) { + BlockVector3 gridMax = getGridMax(row, col, plotMultiplier, level); BlockVector3 bv = BlockVector3.at( gridMax.x() - (roadWidth - roadWidth / 2), - gridMax.y(), + getLevelStartY(level) + plotSizeY - 1, gridMax.z() - (roadWidth - roadWidth / 2)); getLogger().info(String.format("getPlotMax (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } public BlockVector3 getGridMin(int row, int col, int plotMultiplier) { - int level = 0; + return getGridMin(row, col, plotMultiplier, 0); + } - BlockVector3 bv = BlockVector3.at(col * gridSizeX, level * gridSizeY, row * gridSizeZ); + public BlockVector3 getGridMin(int row, int col, int plotMultiplier, int level) { + BlockVector3 bv = BlockVector3.at(col * gridSizeX, getLevelStartY(level), row * gridSizeZ); getLogger().info(String.format("getGridMin (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } public BlockVector3 getGridMax(int row, int col, int plotMultiplier) { - int level = 0; + return getGridMax(row, col, plotMultiplier, 0); + } + public BlockVector3 getGridMax(int row, int col, int plotMultiplier, int level) { BlockVector3 bv = BlockVector3.at( (col + plotMultiplier) * gridSizeX - 1, - (level + 1 /*plotMultiplier*/) * gridSizeY - 1, + getLevelStartY(level) + plotSizeY - 1, (row + plotMultiplier) * gridSizeZ - 1); getLogger().info(String.format("getGridMax (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; @@ -950,6 +1046,19 @@ public class MetropolisPlugin extends JavaPlugin { return (cuboid.minZ - roadWidth / 2) / gridSizeZ; } + private int getLevelPitchY() { + return plotSizeY + roadWidth; + } + + private int getEffectiveMaxLevels() { + int maxWorldLevels = Math.max(1, (world.getMaxHeight() - roadLevel) / getLevelPitchY()); + return clampMaxLevels(maxLevels, maxWorldLevels); + } + + private int getLevelStartY(int level) { + return roadLevel + level * getLevelPitchY(); + } + private void setHomeOccupied( UUID ownerId, String ownerName, @@ -1068,7 +1177,7 @@ public class MetropolisPlugin extends JavaPlugin { private void generateSign(Cuboid plotCuboid, String playerName) { Block signBlock = - world.getBlockAt(plotCuboid.getCenterX(), roadLevel + 1, plotCuboid.getCenterZ()); + world.getBlockAt(plotCuboid.getCenterX(), plotCuboid.minY + 1, plotCuboid.getCenterZ()); signBlock.setType(Material.OAK_SIGN); Sign sign = (Sign) signBlock.getState(); sign.setLine(0, "Home of"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cfe6540..7affced 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -18,13 +18,22 @@ plot: sign: generate: true road: - width: 4 - clearSpaceAbove: 66 - level: 62 - material: COBBLESTONE - supports: - generate: true - material: STONE + avenues: + width: 4 + clearSpaceAbove: 66 + level: 62 + material: COBBLESTONE + supports: + generate: true + material: STONE + streets: + width: 4 + clearSpaceAbove: 66 + level: 62 + material: COBBLESTONE + supports: + generate: true + material: STONE spawn: generate: true setAsWorldSpawn: true diff --git a/src/main/resources/examples/multi-level.yml b/src/main/resources/examples/multi-level.yml new file mode 100644 index 0000000..46e1c3e --- /dev/null +++ b/src/main/resources/examples/multi-level.yml @@ -0,0 +1,59 @@ +plot: + sizeX: 32 + sizeY: 10 + sizeZ: 32 + offsetX: 0 + offsetY: 0 + offsetZ: 0 + multiplier: 1 + maxLevels: 10 + maxPerPlayer: 1 + initial: 1 + floor: + generate: true + clearSpaceAbove: 0 + material: GRASS_BLOCK + supports: + generate: true + material: STONE + sign: + generate: true +road: + avenues: + width: 4 + clearSpaceAbove: 9 + level: 62 + material: COBBLESTONE + supports: + generate: true + material: STONE + stairs: + generate: true + material: COBBLESTONE_STAIRS + everyNBlocks: 4 + streets: + width: 4 + clearSpaceAbove: 9 + level: 62 + material: COBBLESTONE + supports: + generate: true + material: STONE + stairs: + generate: false +spawn: + generate: true + setAsWorldSpawn: true + material: GRASS_BLOCK +wall: + generate: true + material: BEDROCK + height: 10 +worldname: world +userOverrides: +- username: majinnaibu + plotMultiplier: 2 + maxPlots: 2 +- username: hoggrim + plotMultiplier: 2 + maxPlots: 2 diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java index 622d9cf..8adffc2 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java @@ -75,4 +75,13 @@ class MetropolisHomeAcquireTest { assertEquals(0, zero); assertFalse(MetropolisPlugin.shouldAutoGenerateInitialHome(zero)); } + + @Test + void keepsLevelCountsWithinTheConfiguredAndWorldBounds() { + assertEquals(1, MetropolisPlugin.normalizeMaxLevels(0)); + assertEquals(4, MetropolisPlugin.normalizeMaxLevels(4)); + assertEquals(3, MetropolisPlugin.clampMaxLevels(10, 3)); + assertEquals(1, MetropolisPlugin.clampMaxLevels(2, 1)); + assertEquals(1, MetropolisPlugin.clampMaxLevels(0, 0)); + } } From fd5bf785d3564155308efbddcff490ff2038c926 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 06:55:39 -0700 Subject: [PATCH 03/10] Add upper-road stairwell generation --- Human-Tests.md | 5 +- README.md | 2 + .../metropolis/AvenueStairwayBuilder.java | 63 +++++++++ .../metropolis/AvenueStairwayLayout.java | 97 +++++++++++++ .../metropolis/MetropolisPlugin.java | 130 +++++++++++++++--- src/main/resources/config.yml | 2 + src/main/resources/examples/multi-level.yml | 1 + .../metropolis/AvenueStairwayBuilderTest.java | 95 +++++++++++++ .../metropolis/AvenueStairwayLayoutTest.java | 87 ++++++++++++ 9 files changed, 465 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 3398b48..2dfa16a 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. +- 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. - Allocate plots in several directions and near the world origin. Check road joins, plot dimensions, City region expansion, and boundary coordinates for symmetry and off-by-one errors. @@ -72,8 +73,10 @@ 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 28 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 34 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. - `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 ace68fe..983a656 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p `plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. The per-level stack is configured with `plot.sizeY` and `plot.maxLevels`: `sizeY` is the full height of one plot level, and `maxLevels` is the number of stacked levels that can be allocated without exceeding the world build height. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. +Enable `road.avenues.stairs.generate` to add a centered stair run as part of the upper-level road. Set `material`, `width`, and `everyNBlocks`; the stair width must be narrower than `road.avenues.width`. The lower road is left unchanged and provides the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The first implementation ascends along positive Z on the east-side avenue and leaves the three blocks before the top tread open in the upper road, only across the stair width. + 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`. 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: diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java new file mode 100644 index 0000000..d556f56 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.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 com.majinnaibu.bukkitplugins.metropolis.AvenueStairwayLayout.Flight; +import com.majinnaibu.bukkitplugins.metropolis.AvenueStairwayLayout.Step; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Bisected; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Stairs; + +public final class AvenueStairwayBuilder { + private AvenueStairwayBuilder() {} + + public static void build(World world, Flight flight, Material stairMaterial) { + for (Step step : flight.stairBlocks()) { + Block stairBlock = world.getBlockAt(step.x(), step.y(), step.z()); + stairBlock.setType(stairMaterial, false); + BlockData blockData = stairBlock.getBlockData(); + if (blockData instanceof Stairs stairs) { + stairs.setFacing(BlockFace.SOUTH); + stairs.setHalf(Bisected.Half.BOTTOM); + stairs.setShape(Stairs.Shape.STRAIGHT); + stairBlock.setBlockData(stairs, false); + } + } + + for (Step backing : flight.invertedBackingBlocks()) { + Block supportBlock = world.getBlockAt(backing.x(), backing.y(), backing.z()); + supportBlock.setType(stairMaterial, false); + BlockData supportData = supportBlock.getBlockData(); + if (supportData instanceof Stairs stairs) { + stairs.setFacing(BlockFace.NORTH); + stairs.setHalf(Bisected.Half.TOP); + stairs.setShape(Stairs.Shape.STRAIGHT); + supportBlock.setBlockData(stairs, false); + } + } + + for (Step opening : flight.roadOpenings()) { + world.getBlockAt(opening.x(), opening.y(), opening.z()).setType(Material.AIR, false); + } + } +} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java new file mode 100644 index 0000000..95b8f4c --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java @@ -0,0 +1,97 @@ +/* +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 AvenueStairwayLayout { + private AvenueStairwayLayout() {} + + public static List centeredEastAvenue( + Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise) { + int plotLength = plot.maxZ - plot.minZ + 1; + if (roadWidth < 1 + || stairWidth < 1 + || stairWidth >= roadWidth + || rise < 1 + || rise + 2 > plotLength) { + return List.of(); + } + + int firstX = plot.maxX + 1 + (roadWidth - stairWidth) / 2; + int firstZ = plot.minZ + (plotLength - rise) / 2 + 1; + List steps = new ArrayList<>(stairWidth * rise); + for (int riseIndex = 0; riseIndex < rise; riseIndex++) { + for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { + steps.add(new Step(firstX + widthIndex, lowerY + riseIndex + 1, firstZ + riseIndex)); + } + } + return List.copyOf(steps); + } + + public static List upperRoadOpening( + Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { + List stairs = centeredEastAvenue(plot, roadWidth, stairWidth, lowerY, rise); + if (stairs.isEmpty() || openingLength < 1) { + return List.of(); + } + + Step topStair = stairs.get(stairs.size() - 1); + int firstX = stairs.getFirst().x(); + int upperRoadY = lowerY + rise; + List opening = new ArrayList<>(stairWidth * openingLength); + for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { + for (int lengthIndex = openingLength; lengthIndex > 0; lengthIndex--) { + opening.add(new Step(firstX + widthIndex, upperRoadY, topStair.z() - lengthIndex)); + } + } + return List.copyOf(opening); + } + + public static Flight forUpperRoad( + Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { + List stairs = centeredEastAvenue(plot, roadWidth, stairWidth, lowerY, rise); + if (stairs.isEmpty()) { + return new Flight(List.of(), List.of(), List.of()); + } + + List invertedBackings = new ArrayList<>(); + for (Step stair : stairs) { + if (stair.y() > lowerY + 1) { + invertedBackings.add(new Step(stair.x(), stair.y() - 1, stair.z())); + } + } + + return new Flight( + stairs, + invertedBackings, + upperRoadOpening(plot, roadWidth, stairWidth, lowerY, rise, openingLength)); + } + + public record Step(int x, int y, int z) {} + + public record Flight( + List stairBlocks, List invertedBackingBlocks, List roadOpenings) { + public Flight { + stairBlocks = List.copyOf(stairBlocks); + invertedBackingBlocks = List.copyOf(invertedBackingBlocks); + roadOpenings = List.copyOf(roadOpenings); + } + } +} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index ccd1e67..9e563bf 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -56,6 +56,7 @@ import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.Sign; +import org.bukkit.block.data.type.Stairs; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -100,11 +101,16 @@ public class MetropolisPlugin extends JavaPlugin { private int maxLevels = 1; private int gridSizeZ = 28; private int roadWidth = 4; + private int avenueWidth = 4; private int roadLevel = 62; private int spaceAboveRoad = 2; private Material roadMaterial = Material.COBBLESTONE; private boolean generateRoadSupports = true; private Material roadSupportMaterial = Material.STONE; + private boolean generateAvenueStairs; + private Material avenueStairMaterial = Material.COBBLESTONE_STAIRS; + private int avenueStairWidth; + private int avenueStairsEveryNBlocks; private String worldName = "world"; private boolean generateFloor = false; private Material floorMaterial = Material.GRASS_BLOCK; @@ -190,6 +196,7 @@ public class MetropolisPlugin extends JavaPlugin { generateSign = safeGetBooleanFromConfig(config, "plot.sign.generate"); roadWidth = safeGetIntFromConfig(config, "road.streets.width", "road.avenues.width", "road.width"); + avenueWidth = safeGetIntFromConfig(config, "road.avenues.width"); spaceAboveRoad = safeGetIntFromConfig( config, @@ -213,6 +220,32 @@ public class MetropolisPlugin extends JavaPlugin { "road.streets.supports.material", "road.avenues.supports.material", "road.supports.material"); + generateAvenueStairs = safeGetBooleanFromConfig(config, "road.avenues.stairs.generate"); + if (generateAvenueStairs) { + avenueStairMaterial = safeGetMaterialFromConfig(config, "road.avenues.stairs.material"); + avenueStairWidth = safeGetIntFromConfig(config, "road.avenues.stairs.width"); + avenueStairsEveryNBlocks = safeGetIntFromConfig(config, "road.avenues.stairs.everyNBlocks"); + + if (avenueStairWidth < 1 || avenueStairWidth >= avenueWidth) { + getLogger() + .severe( + "road.avenues.stairs.width must be positive and narrower than road.avenues.width;" + + " avenue stairs are disabled."); + generateAvenueStairs = false; + } + if (avenueStairsEveryNBlocks < 1) { + getLogger() + .severe( + "road.avenues.stairs.everyNBlocks must be positive; avenue stairs are disabled."); + generateAvenueStairs = false; + } + if (!(avenueStairMaterial.createBlockData() instanceof Stairs)) { + getLogger() + .severe( + "road.avenues.stairs.material must be a stair block; avenue stairs are disabled."); + generateAvenueStairs = false; + } + } generateSpawn = safeGetBooleanFromConfig(config, "spawn.generate"); setWorldSpawn = safeGetBooleanFromConfig(config, "spawn.setAsWorldSpawn"); spawnFloorMaterial = safeGetMaterialFromConfig(config, "spawn.material"); @@ -695,18 +728,19 @@ public class MetropolisPlugin extends JavaPlugin { } private void createRoads(Cuboid plotCuboid, int roadMask) { + if (plotCuboid == null) { + if (DEBUG) { + getLogger().warning("plotCuboid is null"); + } + return; + } + if (roadWidth > 0) { int x = 0; int y = plotCuboid.minY; int z = 0; - if (plotCuboid == null) { - if (DEBUG) { - getLogger().warning("plotCuboid is null"); - } - return; - } - + List stairOpenings = getUpperRoadOpenings(plotCuboid, y); int roadWidth1 = roadWidth / 2; int roadWidth2 = roadWidth - roadWidth1; @@ -714,7 +748,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -723,7 +757,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -732,7 +766,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -741,7 +775,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -750,7 +784,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -759,7 +793,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -768,7 +802,7 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } @@ -777,10 +811,15 @@ public class MetropolisPlugin extends JavaPlugin { 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); + setRoad(x, y, z, stairOpenings); } } } + + int upperLevel = Math.floorDiv(y - roadLevel, getLevelPitchY()); + if (upperLevel > 0 && getLevelStartY(upperLevel) == y) { + generateAvenueStairs(plotCuboid, upperLevel - 1); + } } } @@ -788,7 +827,11 @@ public class MetropolisPlugin extends JavaPlugin { createRoads(plotCuboid, ROAD_NORTH | ROAD_SOUTH | ROAD_EAST | ROAD_WEST); } - private void setRoad(int x, int y, int z) { + private void setRoad(int x, int y, int z, List 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); @@ -1193,6 +1236,61 @@ public class MetropolisPlugin extends JavaPlugin { sign.update(true); } + private void generateAvenueStairs(Cuboid plotCuboid, int lowerLevel) { + if (!generateAvenueStairs || lowerLevel < 0 || lowerLevel >= getEffectiveMaxLevels() - 1) { + return; + } + + int avenueBlockIndex = getPlotZFromMin(plotCuboid); + if (Math.floorMod(avenueBlockIndex + 1, avenueStairsEveryNBlocks) != 0) { + return; + } + + int lowerY = getLevelStartY(lowerLevel); + AvenueStairwayLayout.Flight flight = + AvenueStairwayLayout.forUpperRoad( + plotCuboid, avenueWidth, avenueStairWidth, lowerY, getLevelPitchY(), 3); + if (flight.stairBlocks().isEmpty()) { + getLogger() + .warning( + "Avenue stair run does not fit beside plot at " + + plotCuboid.minX + + "," + + plotCuboid.minY + + "," + + plotCuboid.minZ); + return; + } + + AvenueStairwayBuilder.build(world, flight, avenueStairMaterial); + } + + private List getUpperRoadOpenings(Cuboid plotCuboid, int roadY) { + if (!generateAvenueStairs || roadY <= roadLevel) { + return List.of(); + } + + int upperLevel = Math.floorDiv(roadY - roadLevel, getLevelPitchY()); + if (getLevelStartY(upperLevel) != roadY) { + return List.of(); + } + + int lowerLevel = upperLevel - 1; + int avenueBlockIndex = getPlotZFromMin(plotCuboid); + if (lowerLevel < 0 || Math.floorMod(avenueBlockIndex + 1, avenueStairsEveryNBlocks) != 0) { + return List.of(); + } + + return AvenueStairwayLayout.forUpperRoad( + plotCuboid, + avenueWidth, + avenueStairWidth, + getLevelStartY(lowerLevel), + getLevelPitchY(), + 3) + .roadOpenings(); + } + public List getCityBlocks() { return Collections.unmodifiableList(_occupiedPlots); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7affced..b0199f2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -26,6 +26,8 @@ road: supports: generate: true material: STONE + stairs: + generate: false streets: width: 4 clearSpaceAbove: 66 diff --git a/src/main/resources/examples/multi-level.yml b/src/main/resources/examples/multi-level.yml index 46e1c3e..2de07ee 100644 --- a/src/main/resources/examples/multi-level.yml +++ b/src/main/resources/examples/multi-level.yml @@ -31,6 +31,7 @@ road: generate: true material: COBBLESTONE_STAIRS everyNBlocks: 4 + width: 2 streets: width: 4 clearSpaceAbove: 9 diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java new file mode 100644 index 0000000..67a4716 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java @@ -0,0 +1,95 @@ +/* +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.assertNotNull; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +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.HashMap; +import java.util.Map; + +import com.majinnaibu.bukkitplugins.metropolis.AvenueStairwayLayout.Flight; +import com.majinnaibu.bukkitplugins.metropolis.AvenueStairwayLayout.Step; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Bisected; +import org.bukkit.block.data.type.Stairs; +import org.junit.jupiter.api.Test; + +class AvenueStairwayBuilderTest { + @Test + void buildsAscendingAndInvertedStairsAndClearsOnlyThePlannedOpening() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + Flight flight = AvenueStairwayLayout.forUpperRoad(plot, 4, 2, 62, 14, 3); + World world = mock(World.class); + Map blocks = new HashMap<>(); + Map blockData = new HashMap<>(); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenAnswer( + invocation -> { + Step position = + new Step( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2)); + return blocks.computeIfAbsent( + position, + ignored -> { + Block block = mock(Block.class); + Stairs stairs = mock(Stairs.class); + when(block.getBlockData()).thenReturn(stairs); + blockData.put(position, stairs); + return block; + }); + }); + + AvenueStairwayBuilder.build(world, flight, Material.COBBLESTONE_STAIRS); + + for (Step stair : flight.stairBlocks()) { + verify(blocks.get(stair)).setType(Material.COBBLESTONE_STAIRS, false); + } + for (Step backing : flight.invertedBackingBlocks()) { + verify(blocks.get(backing)).setType(Material.COBBLESTONE_STAIRS, false); + } + for (Step opening : flight.roadOpenings()) { + verify(blocks.get(opening)).setType(Material.AIR, false); + } + + Step firstStair = flight.stairBlocks().getFirst(); + Stairs firstStairData = blockData.get(firstStair); + assertNotNull(firstStairData); + verify(firstStairData).setFacing(BlockFace.SOUTH); + verify(firstStairData).setHalf(Bisected.Half.BOTTOM); + + Step firstInvertedBacking = flight.invertedBackingBlocks().getFirst(); + Stairs invertedBackingData = blockData.get(firstInvertedBacking); + assertNotNull(invertedBackingData); + verify(invertedBackingData).setFacing(BlockFace.NORTH); + verify(invertedBackingData).setHalf(Bisected.Half.TOP); + + verify(world, never()).getBlockAt(anyInt(), eq(62), anyInt()); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java new file mode 100644 index 0000000..01dbe39 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java @@ -0,0 +1,87 @@ +/* +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.List; + +import org.junit.jupiter.api.Test; + +class AvenueStairwayLayoutTest { + @Test + void centersStairsAcrossTheAvenueAndAlongThePlotLength() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + + List steps = + AvenueStairwayLayout.centeredEastAvenue(plot, 4, 2, 62, 14); + + assertEquals(28, steps.size()); + assertEquals(new AvenueStairwayLayout.Step(35, 63, 12), steps.get(0)); + assertEquals(new AvenueStairwayLayout.Step(36, 63, 12), steps.get(1)); + assertEquals(new AvenueStairwayLayout.Step(35, 64, 13), steps.get(2)); + assertEquals(new AvenueStairwayLayout.Step(36, 76, 25), steps.get(27)); + } + + @Test + void opensOnlyTheThreeUpperRoadBlocksBeforeTheTopStair() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + + List openings = + AvenueStairwayLayout.upperRoadOpening(plot, 4, 2, 62, 14, 3); + + assertEquals(6, openings.size()); + assertEquals(new AvenueStairwayLayout.Step(35, 76, 22), openings.get(0)); + assertEquals(new AvenueStairwayLayout.Step(35, 76, 23), openings.get(1)); + assertEquals(new AvenueStairwayLayout.Step(35, 76, 24), openings.get(2)); + assertEquals(new AvenueStairwayLayout.Step(36, 76, 24), openings.get(5)); + } + + @Test + void upperRoadFlightPreservesTheLowerRoadAndPlansInvertedBacking() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + + AvenueStairwayLayout.Flight flight = AvenueStairwayLayout.forUpperRoad(plot, 4, 2, 62, 14, 3); + + assertEquals(28, flight.stairBlocks().size()); + assertEquals(new AvenueStairwayLayout.Step(35, 63, 12), flight.stairBlocks().getFirst()); + assertEquals(new AvenueStairwayLayout.Step(36, 76, 25), flight.stairBlocks().getLast()); + assertEquals(26, flight.invertedBackingBlocks().size()); + assertEquals( + new AvenueStairwayLayout.Step(35, 63, 13), flight.invertedBackingBlocks().getFirst()); + assertTrue(flight.stairBlocks().stream().allMatch(step -> step.y() > 62)); + assertTrue(flight.invertedBackingBlocks().stream().allMatch(step -> step.y() > 62)); + assertEquals(6, flight.roadOpenings().size()); + assertTrue(flight.roadOpenings().stream().allMatch(step -> step.y() == 76)); + } + + @Test + void rejectsAStairRunThatCannotFitWithinThePlotLength() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 13); + + assertTrue(AvenueStairwayLayout.centeredEastAvenue(plot, 4, 2, 62, 14).isEmpty()); + } + + @Test + void rejectsStairsThatAreNotNarrowerThanTheAvenue() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + + assertTrue(AvenueStairwayLayout.centeredEastAvenue(plot, 4, 4, 62, 14).isEmpty()); + } +} From 30c31c185bba9a5a7e91409344ba5f100927eb6b Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 07:30:31 -0700 Subject: [PATCH 04/10] Generate complete roads without rewrites --- Human-Tests.md | 5 +- README.md | 2 + .../metropolis/MetropolisPlugin.java | 126 ++++-------------- .../metropolis/RoadBlockWriter.java | 66 +++++++++ .../bukkitplugins/metropolis/RoadLayout.java | 57 ++++++++ .../metropolis/RoadBlockWriterTest.java | 63 +++++++++ .../metropolis/RoadLayoutTest.java | 48 +++++++ 7 files changed, 264 insertions(+), 103 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriter.java create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadBlockWriterTest.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java 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()); + } +} From 917dac9c8e60da27da1f4e85fbb6ce6b355fbcb8 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 07:52:48 -0700 Subject: [PATCH 05/10] Improve multi-level support and stairs --- Human-Tests.md | 9 +- README.md | 4 +- .../metropolis/AvenueStairwayLayout.java | 102 +++++++++++++++--- .../metropolis/MetropolisPlugin.java | 74 ++++++++----- .../metropolis/PlotLevelSupport.java | 45 ++++++++ .../metropolis/AvenueStairwayLayoutTest.java | 33 ++++++ .../metropolis/PlotLevelSupportTest.java | 55 ++++++++++ 7 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupport.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupportTest.java diff --git a/Human-Tests.md b/Human-Tests.md index e67a2d1..58c2ce4 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -23,7 +23,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Join as a new player. Confirm one home is allocated and the welcome message reports its actual bounds. Check the corresponding `h_1_` region has the player as owner and its bounds match the configured plot size. - Set `plot.initial: 0` on a disposable server, restart, and join as a fresh player. Confirm no home is created until the player runs `/metropolis-home-acquire`; then verify the command creates the initial home and makes it active. - Join with a second new player. Confirm the new home is distinct, does not overlap the first home or spawn, and the City region grows to include it. -- Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and stacked plots use the same X/Z allocation pattern at each level. +- Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and upper plots are only allocated above footprints fully supported at every lower level. With a 1×1 spawn and a 2×2 home size, confirm a new home is allocated at base level rather than above the partially supported spawn. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. - Test a user override that grants a different plot multiplier or home limit. Confirm the generated bounds and allowed number of homes match the override, and that the configured global defaults still apply to other players. @@ -32,7 +32,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - 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. +- 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 on both bordering avenues 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 each top tread remain open only across the stair width, with regular road beside each opening. For a 2-plot-long home with `everyNBlocks: 1`, confirm there are two centered runs on each bordering avenue. Repeat with a higher-level plot to confirm lower stair flights remain 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. - Allocate plots in several directions and near the world origin. Check road joins, plot dimensions, City region expansion, and boundary coordinates for symmetry and off-by-one errors. @@ -74,9 +74,10 @@ 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 40 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 45 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. +- `AvenueStairwayLayoutTest`: centered per-segment flights on both avenue sides, per-segment cadence, top-tread alignment, inverted backing plan, exact-width upper-road openings, and invalid-fit rejection. +- `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. - `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. diff --git a/README.md b/README.md index b4031fb..732190d 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p `plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. The per-level stack is configured with `plot.sizeY` and `plot.maxLevels`: `sizeY` is the full height of one plot level, and `maxLevels` is the number of stacked levels that can be allocated without exceeding the world build height. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. -Enable `road.avenues.stairs.generate` to add a centered stair run as part of the upper-level road. Set `material`, `width`, and `everyNBlocks`; the stair width must be narrower than `road.avenues.width`. The lower road is left unchanged and provides the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The first implementation ascends along positive Z on the east-side avenue and leaves the three blocks before the top tread open in the upper road, only across the stair width. +Enable `road.avenues.stairs.generate` to add centered stair runs as part of upper-level roads. Set `material`, `width`, and `everyNBlocks`; the stair width must be narrower than `road.avenues.width`. Runs are centered within each eligible logical plot-sized avenue segment on both bordering avenues. For a 2-plot-long home, `everyNBlocks: 1` creates two runs on each border avenue. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The implementation ascends along positive Z and leaves the three blocks before each top tread open only across the stair width. + +Upper-level plots require complete generated support below: every logical plot cell in their footprint must be occupied by a generated home or spawn at every lower level. If a footprint has gaps below, allocation skips that upper candidate and keeps searching for a supported lower-level location. 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`. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java index 95b8f4c..f458b90 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java @@ -25,29 +25,64 @@ public final class AvenueStairwayLayout { public static List centeredEastAvenue( Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise) { - int plotLength = plot.maxZ - plot.minZ + 1; - if (roadWidth < 1 - || stairWidth < 1 - || stairWidth >= roadWidth - || rise < 1 - || rise + 2 > plotLength) { + return stairBlocks(plot, roadWidth, stairWidth, lowerY, rise, Side.EAST); + } + + public static List forUpperRoadSegments( + Cuboid plot, + int plotSizeZ, + int gridSizeZ, + int firstBlockIndex, + int everyNBlocks, + int avenueWidth, + int stairWidth, + int lowerY, + int rise, + int openingLength) { + if (plotSizeZ < 1 || gridSizeZ < plotSizeZ || everyNBlocks < 1) { return List.of(); } - int firstX = plot.maxX + 1 + (roadWidth - stairWidth) / 2; - int firstZ = plot.minZ + (plotLength - rise) / 2 + 1; - List steps = new ArrayList<>(stairWidth * rise); - for (int riseIndex = 0; riseIndex < rise; riseIndex++) { - for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { - steps.add(new Step(firstX + widthIndex, lowerY + riseIndex + 1, firstZ + riseIndex)); + List flights = new ArrayList<>(); + int segmentIndex = 0; + for (int segmentStart = plot.minZ; + segmentStart <= plot.maxZ; + segmentStart += gridSizeZ, segmentIndex++) { + int avenueBlockIndex = firstBlockIndex + segmentIndex; + if (Math.floorMod(avenueBlockIndex + 1, everyNBlocks) != 0) { + continue; + } + + Cuboid segment = + new Cuboid( + plot.minX, + plot.minY, + segmentStart, + plot.maxX, + plot.maxY, + Math.min(segmentStart + plotSizeZ - 1, plot.maxZ)); + for (Side side : Side.values()) { + List stairs = stairBlocks(segment, avenueWidth, stairWidth, lowerY, rise, side); + if (stairs.isEmpty()) { + continue; + } + List backings = invertedBackings(stairs, lowerY); + List openings = + upperRoadOpening(stairs, avenueWidth, stairWidth, lowerY, rise, openingLength); + flights.add(new Flight(stairs, backings, openings)); } } - return List.copyOf(steps); + return List.copyOf(flights); } public static List upperRoadOpening( Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { List stairs = centeredEastAvenue(plot, roadWidth, stairWidth, lowerY, rise); + return upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength); + } + + private static List upperRoadOpening( + List stairs, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { if (stairs.isEmpty() || openingLength < 1) { return List.of(); } @@ -71,17 +106,50 @@ public final class AvenueStairwayLayout { return new Flight(List.of(), List.of(), List.of()); } + return new Flight( + stairs, + invertedBackings(stairs, lowerY), + upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength)); + } + + private static List invertedBackings(List stairs, int lowerY) { List invertedBackings = new ArrayList<>(); for (Step stair : stairs) { if (stair.y() > lowerY + 1) { invertedBackings.add(new Step(stair.x(), stair.y() - 1, stair.z())); } } + return List.copyOf(invertedBackings); + } - return new Flight( - stairs, - invertedBackings, - upperRoadOpening(plot, roadWidth, stairWidth, lowerY, rise, openingLength)); + private static List stairBlocks( + Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, Side side) { + int plotLength = plot.maxZ - plot.minZ + 1; + if (roadWidth < 1 + || stairWidth < 1 + || stairWidth >= roadWidth + || rise < 1 + || rise + 2 > plotLength) { + return List.of(); + } + + int firstX = + side == Side.EAST + ? plot.maxX + 1 + (roadWidth - stairWidth) / 2 + : plot.minX - roadWidth + (roadWidth - stairWidth) / 2; + int firstZ = plot.minZ + (plotLength - rise) / 2 + 1; + List steps = new ArrayList<>(stairWidth * rise); + for (int riseIndex = 0; riseIndex < rise; riseIndex++) { + for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { + steps.add(new Step(firstX + widthIndex, lowerY + riseIndex + 1, firstZ + riseIndex)); + } + } + return List.copyOf(steps); + } + + public enum Side { + EAST, + WEST } public record Step(int x, int y, int z) {} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 17c3acd..262854a 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -828,7 +828,7 @@ public class MetropolisPlugin extends JavaPlugin { for (int level = 0; level < levelCount; level++) { // Top for (col = min; col <= max; col++) { - if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (isAvailableSupportedPlot(row, col, plotMultiplier, level)) { if (DEBUG) { getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); } @@ -841,7 +841,7 @@ public class MetropolisPlugin extends JavaPlugin { // Right side col = max; for (row = min + 1; row < max; row++) { - if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (isAvailableSupportedPlot(row, col, plotMultiplier, level)) { if (DEBUG) { getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); } @@ -854,7 +854,7 @@ public class MetropolisPlugin extends JavaPlugin { // Bottom row = max; for (col = max; col >= min; col--) { - if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (isAvailableSupportedPlot(row, col, plotMultiplier, level)) { if (DEBUG) { getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); } @@ -867,7 +867,7 @@ public class MetropolisPlugin extends JavaPlugin { // Left col = min; for (row = max; row > min; row--) { - if (!areBlocksOccupied(row, col, plotMultiplier, level)) { + if (isAvailableSupportedPlot(row, col, plotMultiplier, level)) { if (row != 0 || col != 0) { if (DEBUG) { getLogger().info(String.format("row: %d, col: %d, level: %d", row, col, level)); @@ -895,6 +895,24 @@ public class MetropolisPlugin extends JavaPlugin { getPlotMin(row, col, plotMultiplier, 0), getPlotMax(row, col, plotMultiplier, 0)); } + private boolean isAvailableSupportedPlot(int row, int col, int plotMultiplier, int level) { + return !areBlocksOccupied(row, col, plotMultiplier, level) + && PlotLevelSupport.hasCompleteSupport( + row, col, plotMultiplier, level, this::isGeneratedLogicalBlock); + } + + private boolean isGeneratedLogicalBlock(int row, int col, int level) { + Cuboid gridCell = new Cuboid(getGridMin(row, col, 1, level), getGridMax(row, col, 1, level)); + if (_occupiedPlots != null) { + for (Plot plot : _occupiedPlots) { + if (plot instanceof PlayerHome && plot.getCuboid().intersects(gridCell)) { + return true; + } + } + } + return _spawnCuboid != null && _spawnCuboid.intersects(gridCell); + } + private void resizeCityRegion() { size = calculateCitySize(); ProtectedRegion cityRegion = regionManager.getRegion("City"); @@ -1149,16 +1167,9 @@ public class MetropolisPlugin extends JavaPlugin { return; } - int avenueBlockIndex = getPlotZFromMin(plotCuboid); - if (Math.floorMod(avenueBlockIndex + 1, avenueStairsEveryNBlocks) != 0) { - return; - } - int lowerY = getLevelStartY(lowerLevel); - AvenueStairwayLayout.Flight flight = - AvenueStairwayLayout.forUpperRoad( - plotCuboid, avenueWidth, avenueStairWidth, lowerY, getLevelPitchY(), 3); - if (flight.stairBlocks().isEmpty()) { + List flights = getAvenueStairFlights(plotCuboid, lowerLevel); + if (flights.isEmpty()) { getLogger() .warning( "Avenue stair run does not fit beside plot at " @@ -1170,7 +1181,9 @@ public class MetropolisPlugin extends JavaPlugin { return; } - AvenueStairwayBuilder.build(world, flight, avenueStairMaterial); + for (AvenueStairwayLayout.Flight flight : flights) { + AvenueStairwayBuilder.build(world, flight, avenueStairMaterial); + } } private Set getUpperRoadOpenings(Cuboid plotCuboid, int roadY) { @@ -1198,19 +1211,32 @@ public class MetropolisPlugin extends JavaPlugin { } int lowerLevel = upperLevel - 1; - int avenueBlockIndex = getPlotZFromMin(plotCuboid); - if (lowerLevel < 0 || Math.floorMod(avenueBlockIndex + 1, avenueStairsEveryNBlocks) != 0) { + if (lowerLevel < 0) { return List.of(); } - return AvenueStairwayLayout.forUpperRoad( - plotCuboid, - avenueWidth, - avenueStairWidth, - getLevelStartY(lowerLevel), - getLevelPitchY(), - 3) - .roadOpenings(); + return getAvenueStairFlights(plotCuboid, lowerLevel).stream() + .flatMap(flight -> flight.roadOpenings().stream()) + .toList(); + } + + private List getAvenueStairFlights( + Cuboid plotCuboid, int lowerLevel) { + if (!generateAvenueStairs || lowerLevel < 0 || lowerLevel >= getEffectiveMaxLevels() - 1) { + return List.of(); + } + + return AvenueStairwayLayout.forUpperRoadSegments( + plotCuboid, + plotSizeZ, + gridSizeZ, + getPlotZFromMin(plotCuboid), + avenueStairsEveryNBlocks, + avenueWidth, + avenueStairWidth, + getLevelStartY(lowerLevel), + getLevelPitchY(), + 3); } public List getCityBlocks() { diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupport.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupport.java new file mode 100644 index 0000000..f9e4c23 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupport.java @@ -0,0 +1,45 @@ +/* +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; + +public final class PlotLevelSupport { + private PlotLevelSupport() {} + + public static boolean hasCompleteSupport( + int row, int col, int multiplier, int targetLevel, GeneratedCellLookup generatedCellLookup) { + if (multiplier < 1 || targetLevel < 0) { + return false; + } + + for (int supportLevel = 0; supportLevel < targetLevel; supportLevel++) { + for (int rowOffset = 0; rowOffset < multiplier; rowOffset++) { + for (int colOffset = 0; colOffset < multiplier; colOffset++) { + if (!generatedCellLookup.isGenerated(row + rowOffset, col + colOffset, supportLevel)) { + return false; + } + } + } + } + return true; + } + + @FunctionalInterface + public interface GeneratedCellLookup { + boolean isGenerated(int row, int col, int level); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java index 01dbe39..390a3ca 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java @@ -71,6 +71,39 @@ class AvenueStairwayLayoutTest { assertTrue(flight.roadOpenings().stream().allMatch(step -> step.y() == 76)); } + @Test + void createsOneCenteredStairFlightPerLogicalPlotOnBothAvenueBorders() { + Cuboid twoByTwoPlot = new Cuboid(2, 62, 2, 69, 71, 69); + + List flights = + AvenueStairwayLayout.forUpperRoadSegments(twoByTwoPlot, 32, 36, 0, 1, 4, 2, 62, 14, 3); + + assertEquals(4, flights.size()); + assertEquals( + new AvenueStairwayLayout.Step(71, 63, 12), flights.get(0).stairBlocks().getFirst()); + assertEquals( + new AvenueStairwayLayout.Step(-1, 63, 12), flights.get(1).stairBlocks().getFirst()); + assertEquals( + new AvenueStairwayLayout.Step(71, 63, 48), flights.get(2).stairBlocks().getFirst()); + assertEquals( + new AvenueStairwayLayout.Step(-1, 63, 48), flights.get(3).stairBlocks().getFirst()); + assertTrue(flights.stream().allMatch(flight -> flight.stairBlocks().getLast().y() == 76)); + } + + @Test + void appliesStairCadenceToEachLogicalPlotSegment() { + Cuboid twoByTwoPlot = new Cuboid(2, 62, 2, 69, 71, 69); + + List flights = + AvenueStairwayLayout.forUpperRoadSegments(twoByTwoPlot, 32, 36, 0, 2, 4, 2, 62, 14, 3); + + assertEquals(2, flights.size()); + assertEquals( + new AvenueStairwayLayout.Step(71, 63, 48), flights.getFirst().stairBlocks().getFirst()); + assertEquals( + new AvenueStairwayLayout.Step(-1, 63, 48), flights.getLast().stairBlocks().getFirst()); + } + @Test void rejectsAStairRunThatCannotFitWithinThePlotLength() { Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 13); diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupportTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupportTest.java new file mode 100644 index 0000000..bf59802 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotLevelSupportTest.java @@ -0,0 +1,55 @@ +/* +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class PlotLevelSupportTest { + @Test + void rejectsUpperPlotWhenAnyLogicalCellBelowIsMissing() { + Set generatedCells = Set.of("0:0:0", "0:1:0", "1:0:0"); + + assertFalse( + PlotLevelSupport.hasCompleteSupport( + 0, 0, 2, 1, (row, col, level) -> generatedCells.contains(key(row, col, level)))); + } + + @Test + void requiresEveryLogicalCellAtEveryLowerLevel() { + Set generatedCells = + Set.of("0:0:0", "0:1:0", "1:0:0", "1:1:0", "0:0:1", "0:1:1", "1:0:1", "1:1:1"); + + assertTrue( + PlotLevelSupport.hasCompleteSupport( + 0, 0, 2, 2, (row, col, level) -> generatedCells.contains(key(row, col, level)))); + } + + @Test + void baseLevelDoesNotRequireLowerSupport() { + assertTrue(PlotLevelSupport.hasCompleteSupport(3, 4, 2, 0, (row, col, level) -> false)); + } + + private static String key(int row, int col, int level) { + return row + ":" + col + ":" + level; + } +} From d68f7543ea5c17bf5dbdd04852853a214369ce25 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 07:57:51 -0700 Subject: [PATCH 06/10] Add configurable spawn dimensions --- Human-Tests.md | 4 +- README.md | 2 + .../metropolis/MetropolisPlugin.java | 43 +++++++++++-- .../bukkitplugins/metropolis/SpawnLayout.java | 51 +++++++++++++++ src/main/resources/config.yml | 3 + src/main/resources/examples/multi-level.yml | 3 + .../metropolis/SpawnLayoutTest.java | 64 +++++++++++++++++++ 7 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 58c2ce4..0237636 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -24,6 +24,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Set `plot.initial: 0` on a disposable server, restart, and join as a fresh player. Confirm no home is created until the player runs `/metropolis-home-acquire`; then verify the command creates the initial home and makes it active. - Join with a second new player. Confirm the new home is distinct, does not overlap the first home or spawn, and the City region grows to include it. - Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and upper plots are only allocated above footprints fully supported at every lower level. With a 1×1 spawn and a 2×2 home size, confirm a new home is allocated at base level rather than above the partially supported spawn. +- Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to distinct positive values on a fresh disposable world. Confirm the Spawn region spans the corresponding plot-plus-road logical cells horizontally and the configured plot-height multiple vertically. Check blocks outside those bounds remain unchanged; configure a height exceeding the world's build limit and confirm startup rejects it clearly. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. - Test a user override that grants a different plot multiplier or home limit. Confirm the generated bounds and allowed number of homes match the override, and that the configured global defaults still apply to other players. @@ -74,10 +75,11 @@ 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 45 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 49 tests covering: - `AvenueStairwayLayoutTest`: centered per-segment flights on both avenue sides, per-segment cadence, top-tread alignment, inverted backing plan, exact-width upper-road openings, and invalid-fit rejection. - `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. +- `SpawnLayoutTest`: default one-cell bounds, independent multipliers, and build-height validation. - `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. diff --git a/README.md b/README.md index 732190d..fd6f197 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p `plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. The per-level stack is configured with `plot.sizeY` and `plot.maxLevels`: `sizeY` is the full height of one plot level, and `maxLevels` is the number of stacked levels that can be allocated without exceeding the world build height. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. +Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to positive logical-grid multipliers. X and Z include the matching plot dimension plus road spacing; Y sets the spawn volume's height upward from `road.level` in multiples of `plot.sizeY`. All default to `1`. A newly created Spawn region must fit within the world's build height, and generation clears only within that region's configured vertical bounds. Existing saved Spawn regions retain their saved bounds. + Enable `road.avenues.stairs.generate` to add centered stair runs as part of upper-level roads. Set `material`, `width`, and `everyNBlocks`; the stair width must be narrower than `road.avenues.width`. Runs are centered within each eligible logical plot-sized avenue segment on both bordering avenues. For a 2-plot-long home, `everyNBlocks: 1` creates two runs on each border avenue. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The implementation ascends along positive Z and leaves the three blocks before each top tread open only across the stair width. Upper-level plots require complete generated support below: every logical plot cell in their footprint must be occupied by a generated home or spawn at every lower level. If a footprint has gaps below, allocation skips that upper candidate and keeps searching for a supported lower-level location. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 262854a..dda41ea 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -120,6 +120,9 @@ public class MetropolisPlugin extends JavaPlugin { private boolean generateSign = false; private boolean generateSpawn = true; private boolean setWorldSpawn = true; + private int spawnSizeX = 1; + private int spawnSizeY = 1; + private int spawnSizeZ = 1; private Material spawnFloorMaterial = Material.COBBLESTONE; private boolean generateFloorSupports = false; private Material floorSupportMaterial = Material.STONE; @@ -250,6 +253,9 @@ public class MetropolisPlugin extends JavaPlugin { } generateSpawn = safeGetBooleanFromConfig(config, "spawn.generate"); setWorldSpawn = safeGetBooleanFromConfig(config, "spawn.setAsWorldSpawn"); + spawnSizeX = optionalIntFromConfig(config, "spawn.sizeX", 1); + spawnSizeY = optionalIntFromConfig(config, "spawn.sizeY", 1); + spawnSizeZ = optionalIntFromConfig(config, "spawn.sizeZ", 1); spawnFloorMaterial = safeGetMaterialFromConfig(config, "spawn.material"); generateWall = safeGetBooleanFromConfig(config, "wall.generate"); wallMaterial = safeGetMaterialFromConfig(config, "wall.material"); @@ -330,7 +336,8 @@ public class MetropolisPlugin extends JavaPlugin { _spawnRegion = regionManager.getRegion("Spawn"); if (_spawnRegion == null) { - _spawnRegion = new ProtectedCuboidRegion("Spawn", getPlotMin(0, 0, 1), getPlotMax(0, 0, 1)); + Cuboid spawnCuboid = createSpawnCuboid(); + _spawnRegion = new ProtectedCuboidRegion("Spawn", spawnCuboid.getMin(), spawnCuboid.getMax()); _spawnRegion.setPriority(1); _spawnRegion.setFlag(Flags.PVP, StateFlag.State.DENY); _spawnRegion.setFlag(Flags.MOB_DAMAGE, StateFlag.State.DENY); @@ -552,18 +559,18 @@ public class MetropolisPlugin extends JavaPlugin { if (generateSpawn) { int x = 0; - int y = roadLevel; + int y = _spawnCuboid.minY; int z = 0; // floor for (x = _spawnCuboid.getMinX(); x <= _spawnCuboid.getMaxX(); x++) { for (z = _spawnCuboid.getMinZ(); z <= _spawnCuboid.getMaxZ(); z++) { - for (y = roadLevel + 1; y < world.getMaxHeight(); y++) { + for (y = _spawnCuboid.minY + 1; y <= _spawnCuboid.maxY; y++) { Block block = world.getBlockAt(x, y, z); block.setType(Material.AIR); } - y = roadLevel; + y = _spawnCuboid.minY; Block block = world.getBlockAt(x, y, z); block.setType(spawnFloorMaterial); } @@ -574,10 +581,36 @@ public class MetropolisPlugin extends JavaPlugin { } if (setWorldSpawn) { - world.setSpawnLocation(_spawnCuboid.getCenterX(), roadLevel + 1, _spawnCuboid.getCenterZ()); + world.setSpawnLocation( + _spawnCuboid.getCenterX(), _spawnCuboid.minY + 1, _spawnCuboid.getCenterZ()); } } + private Cuboid createSpawnCuboid() { + Cuboid spawnCuboid = + SpawnLayout.bounds( + spawnSizeX, + spawnSizeY, + spawnSizeZ, + plotSizeX, + plotSizeY, + plotSizeZ, + roadWidth, + roadLevel); + if (!SpawnLayout.withinBuildHeight(spawnCuboid, world.getMinHeight(), world.getMaxHeight())) { + getLogger() + .severe( + String.format( + "Configured spawn bounds (%d to %d) exceed world build height (%d to %d).", + spawnCuboid.minY, + spawnCuboid.maxY, + world.getMinHeight(), + world.getMaxHeight() - 1)); + throw new IllegalArgumentException("Configured spawn height exceeds world build height."); + } + return spawnCuboid; + } + private void fillOccupiedPlots() { _occupiedPlots.clear(); _ownedPlots.clear(); diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java new file mode 100644 index 0000000..5217c81 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java @@ -0,0 +1,51 @@ +/* +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; + +public final class SpawnLayout { + private SpawnLayout() {} + + public static Cuboid bounds( + int sizeX, + int sizeY, + int sizeZ, + int plotSizeX, + int plotSizeY, + int plotSizeZ, + int roadWidth, + int roadLevel) { + if (sizeX < 1 || sizeY < 1 || sizeZ < 1 || roadWidth < 0) { + throw new IllegalArgumentException( + "Spawn multipliers must be positive and road width nonnegative"); + } + + int gridSizeX = Math.addExact(plotSizeX, roadWidth); + int gridSizeZ = Math.addExact(plotSizeZ, roadWidth); + int inset = roadWidth / 2; + int maxInset = roadWidth - inset; + int maxX = Math.subtractExact(Math.multiplyExact(sizeX, gridSizeX) - 1, maxInset); + int maxY = Math.addExact(roadLevel, Math.multiplyExact(sizeY, plotSizeY) - 1); + int maxZ = Math.subtractExact(Math.multiplyExact(sizeZ, gridSizeZ) - 1, maxInset); + + return new Cuboid(inset, roadLevel, inset, maxX, maxY, maxZ); + } + + public static boolean withinBuildHeight(Cuboid cuboid, int minHeight, int maxHeight) { + return cuboid.minY >= minHeight && cuboid.maxY < maxHeight; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b0199f2..e715ca1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -39,6 +39,9 @@ road: spawn: generate: true setAsWorldSpawn: true + sizeX: 1 + sizeY: 1 + sizeZ: 1 material: GRASS_BLOCK wall: generate: true diff --git a/src/main/resources/examples/multi-level.yml b/src/main/resources/examples/multi-level.yml index 2de07ee..c9fe07b 100644 --- a/src/main/resources/examples/multi-level.yml +++ b/src/main/resources/examples/multi-level.yml @@ -45,6 +45,9 @@ road: spawn: generate: true setAsWorldSpawn: true + sizeX: 1 + sizeY: 1 + sizeZ: 1 material: GRASS_BLOCK wall: generate: true diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java new file mode 100644 index 0000000..6413a2d --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java @@ -0,0 +1,64 @@ +/* +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class SpawnLayoutTest { + @Test + void defaultMultipliersPreserveTheOnePlotSpawnBounds() { + Cuboid spawn = SpawnLayout.bounds(1, 1, 1, 32, 10, 32, 4, 62); + + assertEqualsBounds(spawn, 2, 62, 2, 33, 71, 33); + } + + @Test + void appliesEachMultiplierToItsOwnAlignedGridDimension() { + Cuboid spawn = SpawnLayout.bounds(2, 3, 4, 32, 10, 32, 4, 62); + + assertEqualsBounds(spawn, 2, 62, 2, 69, 91, 141); + } + + @Test + void validatesTheEntireSpawnHeightAgainstWorldBounds() { + Cuboid spawn = SpawnLayout.bounds(1, 3, 1, 32, 10, 32, 4, 62); + + assertTrue(SpawnLayout.withinBuildHeight(spawn, -64, 320)); + assertFalse(SpawnLayout.withinBuildHeight(spawn, -64, 91)); + } + + @Test + void rejectsNonPositiveSpawnMultipliers() { + assertThrows( + IllegalArgumentException.class, () -> SpawnLayout.bounds(0, 1, 1, 32, 10, 32, 4, 62)); + } + + private static void assertEqualsBounds( + Cuboid cuboid, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + org.junit.jupiter.api.Assertions.assertEquals(minX, cuboid.minX); + org.junit.jupiter.api.Assertions.assertEquals(minY, cuboid.minY); + org.junit.jupiter.api.Assertions.assertEquals(minZ, cuboid.minZ); + org.junit.jupiter.api.Assertions.assertEquals(maxX, cuboid.maxX); + org.junit.jupiter.api.Assertions.assertEquals(maxY, cuboid.maxY); + org.junit.jupiter.api.Assertions.assertEquals(maxZ, cuboid.maxZ); + } +} From 8d99c2e67f42d7947d036fe541b131a8b19556d1 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 08:11:52 -0700 Subject: [PATCH 07/10] Use independent street and avenue settings --- Human-Tests.md | 11 +- README.md | 2 +- .../metropolis/AvenueStairwayBuilder.java | 6 +- .../metropolis/AvenueStairwayLayout.java | 110 +++++-- .../metropolis/MetropolisPlugin.java | 273 +++++++++++------- .../bukkitplugins/metropolis/RoadLayout.java | 58 +++- .../bukkitplugins/metropolis/SpawnLayout.java | 23 +- src/main/resources/config.yml | 8 + src/main/resources/examples/multi-level.yml | 3 + .../metropolis/AvenueStairwayBuilderTest.java | 33 +++ .../metropolis/AvenueStairwayLayoutTest.java | 19 ++ .../metropolis/RoadLayoutTest.java | 23 +- .../metropolis/SpawnLayoutTest.java | 15 +- .../commands/MetropolisHomeAcquireTest.java | 2 +- 14 files changed, 425 insertions(+), 161 deletions(-) diff --git a/Human-Tests.md b/Human-Tests.md index 0237636..9121801 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -33,7 +33,8 @@ Use this checklist for behavior that depends on a real Paper world, player inter - 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 on both bordering avenues 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 each top tread remain open only across the stair width, with regular road beside each opening. For a 2-plot-long home with `everyNBlocks: 1`, confirm there are two centered runs on each bordering avenue. Repeat with a higher-level plot to confirm lower stair flights remain open. +- Configure streets and avenues with different widths, materials, clearances, and support settings. Verify streets along X use only the street options and avenues along Z use only avenue options. +- Enable stairs for both sections with different widths/cadences, each narrower than its road. Generate a plot on a level below `plot.maxLevels`; verify street stairs ascend along positive X at north/south borders and avenue stairs ascend along positive Z at east/west borders. Confirm lower roads remain unchanged, the first treads start one block above and forward from the road-level landing, connected treads use inverted backing, and top treads reach the upper road Y. Check each upper road leaves exactly three blocks open before its top tread only across that stair width. For a 2-plot-wide/long home with cadence 1, confirm two centered runs along each eligible edge. Repeat with higher levels to confirm existing flights remain 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. - Allocate plots in several directions and near the world origin. Check road joins, plot dimensions, City region expansion, and boundary coordinates for symmetry and off-by-one errors. @@ -75,13 +76,13 @@ 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 49 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 53 tests covering: -- `AvenueStairwayLayoutTest`: centered per-segment flights on both avenue sides, per-segment cadence, top-tread alignment, inverted backing plan, exact-width upper-road openings, and invalid-fit rejection. +- `AvenueStairwayLayoutTest`: centered per-segment street and avenue flights, both border directions, independent cadence, top-tread alignment, inverted backing plan, exact-width openings, and invalid-fit rejection. - `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. - `SpawnLayoutTest`: default one-cell bounds, independent multipliers, and build-height validation. -- `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. +- `AvenueStairwayBuilderTest`: configured stair material and facing/half data for avenue and street directions, upside-down backing stairs, exact upper-road AIR placements, and no block writes at lower-road Y. +- `RoadLayoutTest`: independently sized street/avenue bands, correct road-type tagging, 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. diff --git a/README.md b/README.md index fd6f197..094f7e8 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to positive logical-grid multipliers. X and Z include the matching plot dimension plus road spacing; Y sets the spawn volume's height upward from `road.level` in multiples of `plot.sizeY`. All default to `1`. A newly created Spawn region must fit within the world's build height, and generation clears only within that region's configured vertical bounds. Existing saved Spawn regions retain their saved bounds. -Enable `road.avenues.stairs.generate` to add centered stair runs as part of upper-level roads. Set `material`, `width`, and `everyNBlocks`; the stair width must be narrower than `road.avenues.width`. Runs are centered within each eligible logical plot-sized avenue segment on both bordering avenues. For a 2-plot-long home, `everyNBlocks: 1` creates two runs on each border avenue. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The implementation ascends along positive Z and leaves the three blocks before each top tread open only across the stair width. +The `road.streets.*` and `road.avenues.*` sections independently configure width, level, material, clearance, supports, and stairs. Streets run along X; avenues run along Z. Each section's stair settings (`generate`, `material`, `width`, and `everyNBlocks`) use that section's road width and cadence. Street stair runs ascend along positive X at the north/south borders; avenue runs ascend along positive Z at the east/west borders. Runs are centered within each eligible logical plot-sized segment. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The three blocks before each top tread remain open only across the stair width. Upper-level plots require complete generated support below: every logical plot cell in their footprint must be occupied by a generated home or spawn at every lower level. If a footprint has gaps below, allocation skips that upper candidate and keeps searching for a supported lower-level location. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java index d556f56..be7e70a 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilder.java @@ -32,12 +32,14 @@ public final class AvenueStairwayBuilder { private AvenueStairwayBuilder() {} public static void build(World world, Flight flight, Material stairMaterial) { + BlockFace ascendingFacing = flight.side().runsAlongX() ? BlockFace.EAST : BlockFace.SOUTH; + BlockFace descendingFacing = ascendingFacing.getOppositeFace(); for (Step step : flight.stairBlocks()) { Block stairBlock = world.getBlockAt(step.x(), step.y(), step.z()); stairBlock.setType(stairMaterial, false); BlockData blockData = stairBlock.getBlockData(); if (blockData instanceof Stairs stairs) { - stairs.setFacing(BlockFace.SOUTH); + stairs.setFacing(ascendingFacing); stairs.setHalf(Bisected.Half.BOTTOM); stairs.setShape(Stairs.Shape.STRAIGHT); stairBlock.setBlockData(stairs, false); @@ -49,7 +51,7 @@ public final class AvenueStairwayBuilder { supportBlock.setType(stairMaterial, false); BlockData supportData = supportBlock.getBlockData(); if (supportData instanceof Stairs stairs) { - stairs.setFacing(BlockFace.NORTH); + stairs.setFacing(descendingFacing); stairs.setHalf(Bisected.Half.TOP); stairs.setShape(Stairs.Shape.STRAIGHT); supportBlock.setBlockData(stairs, false); diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java index f458b90..a469845 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayout.java @@ -61,15 +61,64 @@ public final class AvenueStairwayLayout { plot.maxX, plot.maxY, Math.min(segmentStart + plotSizeZ - 1, plot.maxZ)); - for (Side side : Side.values()) { + for (Side side : List.of(Side.EAST, Side.WEST)) { List stairs = stairBlocks(segment, avenueWidth, stairWidth, lowerY, rise, side); if (stairs.isEmpty()) { continue; } List backings = invertedBackings(stairs, lowerY); List openings = - upperRoadOpening(stairs, avenueWidth, stairWidth, lowerY, rise, openingLength); - flights.add(new Flight(stairs, backings, openings)); + upperRoadOpening(stairs, avenueWidth, stairWidth, lowerY, rise, openingLength, side); + flights.add(new Flight(side, stairs, backings, openings)); + } + } + return List.copyOf(flights); + } + + public static List forUpperStreetSegments( + Cuboid plot, + int plotSizeX, + int gridSizeX, + int firstBlockIndex, + int everyNBlocks, + int streetWidth, + int stairWidth, + int lowerY, + int rise, + int openingLength) { + if (plotSizeX < 1 || gridSizeX < plotSizeX || everyNBlocks < 1) { + return List.of(); + } + + List flights = new ArrayList<>(); + int segmentIndex = 0; + for (int segmentStart = plot.minX; + segmentStart <= plot.maxX; + segmentStart += gridSizeX, segmentIndex++) { + if (Math.floorMod(firstBlockIndex + segmentIndex + 1, everyNBlocks) != 0) { + continue; + } + + Cuboid segment = + new Cuboid( + segmentStart, + plot.minY, + plot.minZ, + Math.min(segmentStart + plotSizeX - 1, plot.maxX), + plot.maxY, + plot.maxZ); + for (Side side : List.of(Side.NORTH, Side.SOUTH)) { + List stairs = stairBlocks(segment, streetWidth, stairWidth, lowerY, rise, side); + if (stairs.isEmpty()) { + continue; + } + flights.add( + new Flight( + side, + stairs, + invertedBackings(stairs, lowerY), + upperRoadOpening( + stairs, streetWidth, stairWidth, lowerY, rise, openingLength, side))); } } return List.copyOf(flights); @@ -78,22 +127,31 @@ public final class AvenueStairwayLayout { public static List upperRoadOpening( Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { List stairs = centeredEastAvenue(plot, roadWidth, stairWidth, lowerY, rise); - return upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength); + return upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength, Side.EAST); } private static List upperRoadOpening( - List stairs, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { + List stairs, + int roadWidth, + int stairWidth, + int lowerY, + int rise, + int openingLength, + Side side) { if (stairs.isEmpty() || openingLength < 1) { return List.of(); } Step topStair = stairs.get(stairs.size() - 1); - int firstX = stairs.getFirst().x(); + Step firstStair = stairs.getFirst(); int upperRoadY = lowerY + rise; List opening = new ArrayList<>(stairWidth * openingLength); for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { for (int lengthIndex = openingLength; lengthIndex > 0; lengthIndex--) { - opening.add(new Step(firstX + widthIndex, upperRoadY, topStair.z() - lengthIndex)); + opening.add( + side.runsAlongX() + ? new Step(topStair.x() - lengthIndex, upperRoadY, firstStair.z() + widthIndex) + : new Step(firstStair.x() + widthIndex, upperRoadY, topStair.z() - lengthIndex)); } } return List.copyOf(opening); @@ -103,13 +161,14 @@ public final class AvenueStairwayLayout { Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, int openingLength) { List stairs = centeredEastAvenue(plot, roadWidth, stairWidth, lowerY, rise); if (stairs.isEmpty()) { - return new Flight(List.of(), List.of(), List.of()); + return new Flight(Side.EAST, List.of(), List.of(), List.of()); } return new Flight( + Side.EAST, stairs, invertedBackings(stairs, lowerY), - upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength)); + upperRoadOpening(stairs, roadWidth, stairWidth, lowerY, rise, openingLength, Side.EAST)); } private static List invertedBackings(List stairs, int lowerY) { @@ -124,7 +183,7 @@ public final class AvenueStairwayLayout { private static List stairBlocks( Cuboid plot, int roadWidth, int stairWidth, int lowerY, int rise, Side side) { - int plotLength = plot.maxZ - plot.minZ + 1; + int plotLength = side.runsAlongX() ? plot.maxX - plot.minX + 1 : plot.maxZ - plot.minZ + 1; if (roadWidth < 1 || stairWidth < 1 || stairWidth >= roadWidth @@ -133,15 +192,21 @@ public final class AvenueStairwayLayout { return List.of(); } - int firstX = - side == Side.EAST - ? plot.maxX + 1 + (roadWidth - stairWidth) / 2 - : plot.minX - roadWidth + (roadWidth - stairWidth) / 2; - int firstZ = plot.minZ + (plotLength - rise) / 2 + 1; + int firstRun = (side.runsAlongX() ? plot.minX : plot.minZ) + (plotLength - rise) / 2 + 1; + int firstCross = + switch (side) { + case EAST -> plot.maxX + 1 + (roadWidth - stairWidth) / 2; + case WEST -> plot.minX - roadWidth + (roadWidth - stairWidth) / 2; + case NORTH -> plot.minZ - roadWidth + (roadWidth - stairWidth) / 2; + case SOUTH -> plot.maxZ + 1 + (roadWidth - stairWidth) / 2; + }; List steps = new ArrayList<>(stairWidth * rise); for (int riseIndex = 0; riseIndex < rise; riseIndex++) { for (int widthIndex = 0; widthIndex < stairWidth; widthIndex++) { - steps.add(new Step(firstX + widthIndex, lowerY + riseIndex + 1, firstZ + riseIndex)); + steps.add( + side.runsAlongX() + ? new Step(firstRun + riseIndex, lowerY + riseIndex + 1, firstCross + widthIndex) + : new Step(firstCross + widthIndex, lowerY + riseIndex + 1, firstRun + riseIndex)); } } return List.copyOf(steps); @@ -149,13 +214,22 @@ public final class AvenueStairwayLayout { public enum Side { EAST, - WEST + WEST, + NORTH, + SOUTH; + + public boolean runsAlongX() { + return this == NORTH || this == SOUTH; + } } public record Step(int x, int y, int z) {} public record Flight( - List stairBlocks, List invertedBackingBlocks, List roadOpenings) { + Side side, + List stairBlocks, + List invertedBackingBlocks, + List roadOpenings) { public Flight { stairBlocks = List.copyOf(stairBlocks); invertedBackingBlocks = List.copyOf(invertedBackingBlocks); diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index dda41ea..5a91661 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -101,14 +101,23 @@ public class MetropolisPlugin extends JavaPlugin { private int gridSizeX = 28; private int gridSizeY = 256; private int maxLevels = 1; - private int gridSizeZ = 28; - private int roadWidth = 4; + private int streetWidth = 4; private int avenueWidth = 4; + private int gridSizeZ = 28; private int roadLevel = 62; - private int spaceAboveRoad = 2; - private Material roadMaterial = Material.COBBLESTONE; - private boolean generateRoadSupports = true; - private Material roadSupportMaterial = Material.STONE; + private int avenueLevel = 62; + private int streetSpaceAbove = 2; + private int avenueSpaceAbove = 2; + private Material streetMaterial = Material.COBBLESTONE; + private Material avenueMaterial = Material.COBBLESTONE; + private boolean generateStreetSupports = true; + private boolean generateAvenueSupports = true; + private Material streetSupportMaterial = Material.STONE; + private Material avenueSupportMaterial = Material.STONE; + private boolean generateStreetStairs; + private Material streetStairMaterial = Material.COBBLESTONE_STAIRS; + private int streetStairWidth; + private int streetStairsEveryNBlocks; private boolean generateAvenueStairs; private Material avenueStairMaterial = Material.COBBLESTONE_STAIRS; private int avenueStairWidth; @@ -199,32 +208,44 @@ public class MetropolisPlugin extends JavaPlugin { generateFloorSupports = safeGetBooleanFromConfig(config, "plot.floor.supports.generate"); floorSupportMaterial = safeGetMaterialFromConfig(config, "plot.floor.supports.material"); generateSign = safeGetBooleanFromConfig(config, "plot.sign.generate"); - roadWidth = - safeGetIntFromConfig(config, "road.streets.width", "road.avenues.width", "road.width"); + streetWidth = safeGetIntFromConfig(config, "road.streets.width"); avenueWidth = safeGetIntFromConfig(config, "road.avenues.width"); - spaceAboveRoad = - safeGetIntFromConfig( - config, - "road.streets.clearSpaceAbove", - "road.avenues.clearSpaceAbove", - "road.clearSpaceAbove"); - roadLevel = - safeGetIntFromConfig(config, "road.streets.level", "road.avenues.level", "road.level"); - roadMaterial = - safeGetMaterialFromConfig( - config, "road.streets.material", "road.avenues.material", "road.material"); - generateRoadSupports = - safeGetBooleanFromConfig( - config, - "road.streets.supports.generate", - "road.avenues.supports.generate", - "road.supports.generate"); - roadSupportMaterial = - safeGetMaterialFromConfig( - config, - "road.streets.supports.material", - "road.avenues.supports.material", - "road.supports.material"); + streetSpaceAbove = safeGetIntFromConfig(config, "road.streets.clearSpaceAbove"); + avenueSpaceAbove = safeGetIntFromConfig(config, "road.avenues.clearSpaceAbove"); + roadLevel = safeGetIntFromConfig(config, "road.streets.level"); + avenueLevel = safeGetIntFromConfig(config, "road.avenues.level"); + streetMaterial = safeGetMaterialFromConfig(config, "road.streets.material"); + avenueMaterial = safeGetMaterialFromConfig(config, "road.avenues.material"); + generateStreetSupports = safeGetBooleanFromConfig(config, "road.streets.supports.generate"); + generateAvenueSupports = safeGetBooleanFromConfig(config, "road.avenues.supports.generate"); + streetSupportMaterial = safeGetMaterialFromConfig(config, "road.streets.supports.material"); + avenueSupportMaterial = safeGetMaterialFromConfig(config, "road.avenues.supports.material"); + generateStreetStairs = safeGetBooleanFromConfig(config, "road.streets.stairs.generate"); + if (generateStreetStairs) { + streetStairMaterial = safeGetMaterialFromConfig(config, "road.streets.stairs.material"); + streetStairWidth = safeGetIntFromConfig(config, "road.streets.stairs.width"); + streetStairsEveryNBlocks = safeGetIntFromConfig(config, "road.streets.stairs.everyNBlocks"); + + if (streetStairWidth < 1 || streetStairWidth >= streetWidth) { + getLogger() + .severe( + "road.streets.stairs.width must be positive and narrower than road.streets.width;" + + " street stairs are disabled."); + generateStreetStairs = false; + } + if (streetStairsEveryNBlocks < 1) { + getLogger() + .severe( + "road.streets.stairs.everyNBlocks must be positive; street stairs are disabled."); + generateStreetStairs = false; + } + if (!(streetStairMaterial.createBlockData() instanceof Stairs)) { + getLogger() + .severe( + "road.streets.stairs.material must be a stair block; street stairs are disabled."); + generateStreetStairs = false; + } + } generateAvenueStairs = safeGetBooleanFromConfig(config, "road.avenues.stairs.generate"); if (generateAvenueStairs) { avenueStairMaterial = safeGetMaterialFromConfig(config, "road.avenues.stairs.material"); @@ -305,9 +326,12 @@ public class MetropolisPlugin extends JavaPlugin { throw new RuntimeException(String.format("The world %s does not exist", worldName)); } - gridSizeX = plotSizeX + roadWidth; - gridSizeY = Math.max(plotSizeY + roadWidth, 1); - gridSizeZ = plotSizeZ + roadWidth; + if (streetWidth < 0 || avenueWidth < 0) { + throw new IllegalArgumentException("Road widths must not be negative."); + } + gridSizeX = plotSizeX + avenueWidth; + gridSizeY = Math.max(plotSizeY + Math.max(streetWidth, avenueWidth), 1); + gridSizeZ = plotSizeZ + streetWidth; regionManager = WorldGuard.getInstance().getPlatform().getRegionContainer().get(BukkitAdapter.adapt(world)); @@ -372,7 +396,8 @@ public class MetropolisPlugin extends JavaPlugin { } */ - getLogger().info(String.format("roadWidth = %d", roadWidth)); + getLogger() + .info(String.format("streetWidth = %d, avenueWidth = %d", streetWidth, avenueWidth)); } _occupiedPlots = new ArrayList(); @@ -595,7 +620,8 @@ public class MetropolisPlugin extends JavaPlugin { plotSizeX, plotSizeY, plotSizeZ, - roadWidth, + avenueWidth, + streetWidth, roadLevel); if (!SpawnLayout.withinBuildHeight(spawnCuboid, world.getMinHeight(), world.getMaxHeight())) { getLogger() @@ -702,7 +728,7 @@ public class MetropolisPlugin extends JavaPlugin { public static int clampMaxLevels(int requestedLevels, int availableLevels) { int normalizedRequested = normalizeMaxLevels(requestedLevels); - int normalizedAvailable = Math.max(1, availableLevels); + int normalizedAvailable = Math.max(0, availableLevels); return Math.min(normalizedRequested, normalizedAvailable); } @@ -770,28 +796,46 @@ public class MetropolisPlugin extends JavaPlugin { return; } - if (roadWidth > 0) { - int y = plotCuboid.minY; - Set stairOpenings = getUpperRoadOpenings(plotCuboid, y); - - for (RoadLayout.BlockPosition roadBlock : RoadLayout.surroundingRoad(plotCuboid, roadWidth)) { - setRoad(roadBlock.x(), y, roadBlock.z(), stairOpenings); + if (streetWidth > 0 || avenueWidth > 0) { + for (RoadLayout.BlockPosition roadBlock : + RoadLayout.surroundingRoad(plotCuboid, streetWidth, avenueWidth)) { + int roadY = + roadBlock.roadType() == RoadLayout.RoadType.AVENUE + ? plotCuboid.minY + avenueLevel - roadLevel + : plotCuboid.minY; + Set stairOpenings = + getUpperRoadOpenings(plotCuboid, roadY, roadBlock.roadType()); + setRoad(roadBlock.x(), roadY, roadBlock.z(), roadBlock.roadType(), stairOpenings); } - int upperLevel = Math.floorDiv(y - roadLevel, getLevelPitchY()); - if (upperLevel > 0 && getLevelStartY(upperLevel) == y) { - generateAvenueStairs(plotCuboid, upperLevel - 1); + int upperLevel = Math.floorDiv(plotCuboid.minY - roadLevel, getLevelPitchY()); + if (upperLevel > 0 && getLevelStartY(upperLevel) == plotCuboid.minY) { + generateRoadStairs(plotCuboid, upperLevel - 1, RoadLayout.RoadType.AVENUE); + generateRoadStairs(plotCuboid, upperLevel - 1, RoadLayout.RoadType.STREET); } } } - private void setRoad(int x, int y, int z, Set stairOpenings) { + private void setRoad( + int x, + int y, + int z, + RoadLayout.RoadType roadType, + Set stairOpenings) { if (stairOpenings.contains(new AvenueStairwayLayout.Step(x, y, z))) { return; } + boolean useAvenueSettings = roadType == RoadLayout.RoadType.AVENUE; RoadBlockWriter.build( - world, x, y, z, roadMaterial, roadSupportMaterial, generateRoadSupports, spaceAboveRoad); + world, + x, + y, + z, + useAvenueSettings ? avenueMaterial : streetMaterial, + useAvenueSettings ? avenueSupportMaterial : streetSupportMaterial, + useAvenueSettings ? generateAvenueSupports : generateStreetSupports, + useAvenueSettings ? avenueSpaceAbove : streetSpaceAbove); } private boolean isSolidMaterial(Material material) { @@ -853,6 +897,10 @@ public class MetropolisPlugin extends JavaPlugin { int max = ring - (plotMultiplier - 1); boolean done = false; int levelCount = getEffectiveMaxLevels(); + if (levelCount == 0) { + getLogger().severe("No configured plot level fits within the world's build height."); + return null; + } while (!done) { row = min; @@ -921,11 +969,9 @@ public class MetropolisPlugin extends JavaPlugin { } } - if (DEBUG) { - getLogger().info(String.format("row: %d, col: %d", row, col)); - } - return new Cuboid( - getPlotMin(row, col, plotMultiplier, 0), getPlotMax(row, col, plotMultiplier, 0)); + getLogger() + .severe("Unable to find an available supported plot within the allocation search area."); + return null; } private boolean isAvailableSupportedPlot(int row, int col, int plotMultiplier, int level) { @@ -995,7 +1041,7 @@ public class MetropolisPlugin extends JavaPlugin { BlockVector3 bv = BlockVector3.at( - gridMin.x() + roadWidth / 2, getLevelStartY(level), gridMin.z() + roadWidth / 2); + gridMin.x() + avenueWidth / 2, getLevelStartY(level), gridMin.z() + streetWidth / 2); getLogger().info(String.format("getPlotMin (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } @@ -1009,9 +1055,9 @@ public class MetropolisPlugin extends JavaPlugin { BlockVector3 bv = BlockVector3.at( - gridMax.x() - (roadWidth - roadWidth / 2), + gridMax.x() - (avenueWidth - avenueWidth / 2), getLevelStartY(level) + plotSizeY - 1, - gridMax.z() - (roadWidth - roadWidth / 2)); + gridMax.z() - (streetWidth - streetWidth / 2)); getLogger().info(String.format("getPlotMax (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } @@ -1041,22 +1087,37 @@ public class MetropolisPlugin extends JavaPlugin { } private int getPlotXFromMin(Cuboid cuboid) { - return (cuboid.minX - roadWidth / 2) / gridSizeX; + return (cuboid.minX - avenueWidth / 2) / gridSizeX; } private int getPlotZFromMin(Cuboid cuboid) { - return (cuboid.minZ - roadWidth / 2) / gridSizeZ; + return (cuboid.minZ - streetWidth / 2) / gridSizeZ; } private int getLevelPitchY() { - return plotSizeY + roadWidth; + return plotSizeY + Math.max(streetWidth, avenueWidth); } private int getEffectiveMaxLevels() { - int maxWorldLevels = Math.max(1, (world.getMaxHeight() - roadLevel) / getLevelPitchY()); + int availableStreetLevels = getAvailableLevelsAt(roadLevel); + int availableAvenueLevels = + avenueWidth > 0 ? getAvailableLevelsAt(avenueLevel) : availableStreetLevels; + int maxWorldLevels = Math.min(availableStreetLevels, availableAvenueLevels); return clampMaxLevels(maxLevels, maxWorldLevels); } + private int getAvailableLevelsAt(int baseY) { + if (world == null || baseY < world.getMinHeight() || plotSizeY < 1) { + return 0; + } + + int highestPlotBase = world.getMaxHeight() - plotSizeY; + if (baseY > highestPlotBase) { + return 0; + } + return (highestPlotBase - baseY) / getLevelPitchY() + 1; + } + private int getLevelStartY(int level) { return roadLevel + level * getLevelPitchY(); } @@ -1104,6 +1165,9 @@ public class MetropolisPlugin extends JavaPlugin { } homeCuboid = findNextUnownedHomeRegion(multiplier); + if (homeCuboid == null) { + return null; + } getLogger().info("Metropolis Generating home in " + homeCuboid.toString()); @@ -1195,37 +1259,28 @@ public class MetropolisPlugin extends JavaPlugin { sign.update(true); } - private void generateAvenueStairs(Cuboid plotCuboid, int lowerLevel) { - if (!generateAvenueStairs || lowerLevel < 0 || lowerLevel >= getEffectiveMaxLevels() - 1) { - return; - } - - int lowerY = getLevelStartY(lowerLevel); - List flights = getAvenueStairFlights(plotCuboid, lowerLevel); + private void generateRoadStairs(Cuboid plotCuboid, int lowerLevel, RoadLayout.RoadType roadType) { + List flights = + getRoadStairFlights(plotCuboid, lowerLevel, roadType); if (flights.isEmpty()) { - getLogger() - .warning( - "Avenue stair run does not fit beside plot at " - + plotCuboid.minX - + "," - + plotCuboid.minY - + "," - + plotCuboid.minZ); return; } + Material stairMaterial = + roadType == RoadLayout.RoadType.AVENUE ? avenueStairMaterial : streetStairMaterial; for (AvenueStairwayLayout.Flight flight : flights) { - AvenueStairwayBuilder.build(world, flight, avenueStairMaterial); + AvenueStairwayBuilder.build(world, flight, stairMaterial); } } - private Set getUpperRoadOpenings(Cuboid plotCuboid, int roadY) { + private Set getUpperRoadOpenings( + Cuboid plotCuboid, int roadY, RoadLayout.RoadType roadType) { Set openings = new HashSet<>(); - openings.addAll(getUpperRoadOpeningsForPlot(plotCuboid, roadY)); + openings.addAll(getUpperRoadOpeningsForPlot(plotCuboid, roadY, roadType)); if (_occupiedPlots != null) { for (Plot plot : _occupiedPlots) { - if (plot instanceof PlayerHome home && home.getCuboid().minY == roadY) { - openings.addAll(getUpperRoadOpeningsForPlot(home.getCuboid(), roadY)); + if (plot instanceof PlayerHome home) { + openings.addAll(getUpperRoadOpeningsForPlot(home.getCuboid(), roadY, roadType)); } } } @@ -1233,41 +1288,57 @@ public class MetropolisPlugin extends JavaPlugin { } private List getUpperRoadOpeningsForPlot( - Cuboid plotCuboid, int roadY) { - if (!generateAvenueStairs || roadY <= roadLevel) { + Cuboid plotCuboid, int roadY, RoadLayout.RoadType roadType) { + int baseY = roadType == RoadLayout.RoadType.AVENUE ? avenueLevel : roadLevel; + if (!areStairsEnabled(roadType) || roadY <= baseY) { return List.of(); } - int upperLevel = Math.floorDiv(roadY - roadLevel, getLevelPitchY()); - if (getLevelStartY(upperLevel) != roadY) { + int upperLevel = Math.floorDiv(roadY - baseY, getLevelPitchY()); + if (baseY + upperLevel * getLevelPitchY() != roadY || upperLevel < 1) { return List.of(); } - int lowerLevel = upperLevel - 1; - if (lowerLevel < 0) { - return List.of(); - } - - return getAvenueStairFlights(plotCuboid, lowerLevel).stream() + return getRoadStairFlights(plotCuboid, upperLevel - 1, roadType).stream() .flatMap(flight -> flight.roadOpenings().stream()) .toList(); } - private List getAvenueStairFlights( - Cuboid plotCuboid, int lowerLevel) { - if (!generateAvenueStairs || lowerLevel < 0 || lowerLevel >= getEffectiveMaxLevels() - 1) { + private boolean areStairsEnabled(RoadLayout.RoadType roadType) { + return roadType == RoadLayout.RoadType.AVENUE ? generateAvenueStairs : generateStreetStairs; + } + + private List getRoadStairFlights( + Cuboid plotCuboid, int lowerLevel, RoadLayout.RoadType roadType) { + if (!areStairsEnabled(roadType) + || lowerLevel < 0 + || lowerLevel >= getEffectiveMaxLevels() - 1) { return List.of(); } - return AvenueStairwayLayout.forUpperRoadSegments( + if (roadType == RoadLayout.RoadType.AVENUE) { + return AvenueStairwayLayout.forUpperRoadSegments( + plotCuboid, + plotSizeZ, + gridSizeZ, + getPlotZFromMin(plotCuboid), + avenueStairsEveryNBlocks, + avenueWidth, + avenueStairWidth, + avenueLevel + lowerLevel * getLevelPitchY(), + getLevelPitchY(), + 3); + } + + return AvenueStairwayLayout.forUpperStreetSegments( plotCuboid, - plotSizeZ, - gridSizeZ, - getPlotZFromMin(plotCuboid), - avenueStairsEveryNBlocks, - avenueWidth, - avenueStairWidth, - getLevelStartY(lowerLevel), + plotSizeX, + gridSizeX, + getPlotXFromMin(plotCuboid), + streetStairsEveryNBlocks, + streetWidth, + streetStairWidth, + roadLevel + lowerLevel * getLevelPitchY(), getLevelPitchY(), 3); } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java index 2b4f603..f0a05a6 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayout.java @@ -23,35 +23,67 @@ import java.util.List; public final class RoadLayout { private RoadLayout() {} - public static List surroundingRoad(Cuboid plot, int roadWidth) { - if (roadWidth < 1) { + public static List surroundingRoad(Cuboid plot, int streetWidth, int avenueWidth) { + if (streetWidth < 0 || avenueWidth < 0 || streetWidth + avenueWidth == 0) { 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); + blocks, plot.minX, plot.maxX, plot.minZ - streetWidth, plot.minZ - 1, RoadType.STREET); addRectangle( - blocks, plot.maxX + 1, plot.maxX + roadWidth, plot.minZ - roadWidth, plot.minZ - 1); + blocks, plot.minX, plot.maxX, plot.maxZ + 1, plot.maxZ + streetWidth, RoadType.STREET); addRectangle( - blocks, plot.minX - roadWidth, plot.minX - 1, plot.maxZ + 1, plot.maxZ + roadWidth); + blocks, plot.minX - avenueWidth, plot.minX - 1, plot.minZ, plot.maxZ, RoadType.AVENUE); addRectangle( - blocks, plot.maxX + 1, plot.maxX + roadWidth, plot.maxZ + 1, plot.maxZ + roadWidth); + blocks, plot.maxX + 1, plot.maxX + avenueWidth, plot.minZ, plot.maxZ, RoadType.AVENUE); + if (streetWidth > 0 && avenueWidth > 0) { + addRectangle( + blocks, + plot.minX - avenueWidth, + plot.minX - 1, + plot.minZ - streetWidth, + plot.minZ - 1, + RoadType.INTERSECTION); + addRectangle( + blocks, + plot.maxX + 1, + plot.maxX + avenueWidth, + plot.minZ - streetWidth, + plot.minZ - 1, + RoadType.INTERSECTION); + addRectangle( + blocks, + plot.minX - avenueWidth, + plot.minX - 1, + plot.maxZ + 1, + plot.maxZ + streetWidth, + RoadType.INTERSECTION); + addRectangle( + blocks, + plot.maxX + 1, + plot.maxX + avenueWidth, + plot.maxZ + 1, + plot.maxZ + streetWidth, + RoadType.INTERSECTION); + } return List.copyOf(blocks); } private static void addRectangle( - List blocks, int minX, int maxX, int minZ, int maxZ) { + List blocks, int minX, int maxX, int minZ, int maxZ, RoadType roadType) { for (int x = minX; x <= maxX; x++) { for (int z = minZ; z <= maxZ; z++) { - blocks.add(new BlockPosition(x, z)); + blocks.add(new BlockPosition(x, z, roadType)); } } } - public record BlockPosition(int x, int z) {} + public enum RoadType { + STREET, + AVENUE, + INTERSECTION + } + + public record BlockPosition(int x, int z, RoadType roadType) {} } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java index 5217c81..1365dc4 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java @@ -27,22 +27,25 @@ public final class SpawnLayout { int plotSizeX, int plotSizeY, int plotSizeZ, - int roadWidth, + int avenueWidth, + int streetWidth, int roadLevel) { - if (sizeX < 1 || sizeY < 1 || sizeZ < 1 || roadWidth < 0) { + if (sizeX < 1 || sizeY < 1 || sizeZ < 1 || avenueWidth < 0 || streetWidth < 0) { throw new IllegalArgumentException( - "Spawn multipliers must be positive and road width nonnegative"); + "Spawn multipliers must be positive and road widths nonnegative"); } - int gridSizeX = Math.addExact(plotSizeX, roadWidth); - int gridSizeZ = Math.addExact(plotSizeZ, roadWidth); - int inset = roadWidth / 2; - int maxInset = roadWidth - inset; - int maxX = Math.subtractExact(Math.multiplyExact(sizeX, gridSizeX) - 1, maxInset); + int gridSizeX = Math.addExact(plotSizeX, avenueWidth); + int gridSizeZ = Math.addExact(plotSizeZ, streetWidth); + int minX = avenueWidth / 2; + int minZ = streetWidth / 2; + int maxInsetX = avenueWidth - minX; + int maxInsetZ = streetWidth - minZ; + int maxX = Math.subtractExact(Math.multiplyExact(sizeX, gridSizeX) - 1, maxInsetX); int maxY = Math.addExact(roadLevel, Math.multiplyExact(sizeY, plotSizeY) - 1); - int maxZ = Math.subtractExact(Math.multiplyExact(sizeZ, gridSizeZ) - 1, maxInset); + int maxZ = Math.subtractExact(Math.multiplyExact(sizeZ, gridSizeZ) - 1, maxInsetZ); - return new Cuboid(inset, roadLevel, inset, maxX, maxY, maxZ); + return new Cuboid(minX, roadLevel, minZ, maxX, maxY, maxZ); } public static boolean withinBuildHeight(Cuboid cuboid, int minHeight, int maxHeight) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e715ca1..3c7e97f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -28,6 +28,9 @@ road: material: STONE stairs: generate: false + material: COBBLESTONE_STAIRS + everyNBlocks: 4 + width: 2 streets: width: 4 clearSpaceAbove: 66 @@ -36,6 +39,11 @@ road: supports: generate: true material: STONE + stairs: + generate: false + material: COBBLESTONE_STAIRS + everyNBlocks: 4 + width: 2 spawn: generate: true setAsWorldSpawn: true diff --git a/src/main/resources/examples/multi-level.yml b/src/main/resources/examples/multi-level.yml index c9fe07b..20fa9e5 100644 --- a/src/main/resources/examples/multi-level.yml +++ b/src/main/resources/examples/multi-level.yml @@ -42,6 +42,9 @@ road: material: STONE stairs: generate: false + material: COBBLESTONE_STAIRS + everyNBlocks: 4 + width: 2 spawn: generate: true setAsWorldSpawn: true diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java index 67a4716..f4a4886 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayBuilderTest.java @@ -40,6 +40,39 @@ import org.bukkit.block.data.type.Stairs; import org.junit.jupiter.api.Test; class AvenueStairwayBuilderTest { + @Test + void buildsStreetStairsFacingEastWithWestFacingInvertedBacking() { + Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); + Flight flight = + AvenueStairwayLayout.forUpperStreetSegments(plot, 32, 36, 0, 1, 4, 2, 62, 14, 3).getFirst(); + World world = mock(World.class); + Map blocks = new HashMap<>(); + Map blockData = new HashMap<>(); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenAnswer( + invocation -> { + Step position = + new Step( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2)); + return blocks.computeIfAbsent( + position, + ignored -> { + Block block = mock(Block.class); + Stairs stairs = mock(Stairs.class); + when(block.getBlockData()).thenReturn(stairs); + blockData.put(position, stairs); + return block; + }); + }); + + AvenueStairwayBuilder.build(world, flight, Material.COBBLESTONE_STAIRS); + + verify(blockData.get(flight.stairBlocks().getFirst())).setFacing(BlockFace.EAST); + verify(blockData.get(flight.invertedBackingBlocks().getFirst())).setFacing(BlockFace.WEST); + } + @Test void buildsAscendingAndInvertedStairsAndClearsOnlyThePlannedOpening() { Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 33); diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java index 390a3ca..f03f63e 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/AvenueStairwayLayoutTest.java @@ -104,6 +104,25 @@ class AvenueStairwayLayoutTest { new AvenueStairwayLayout.Step(-1, 63, 48), flights.getLast().stairBlocks().getFirst()); } + @Test + void createsStreetFlightsAlongXOnBothNorthAndSouthBorders() { + Cuboid twoByTwoPlot = new Cuboid(2, 62, 2, 69, 71, 69); + + List flights = + AvenueStairwayLayout.forUpperStreetSegments(twoByTwoPlot, 32, 36, 0, 1, 4, 2, 62, 14, 3); + + assertEquals(4, flights.size()); + assertEquals(AvenueStairwayLayout.Side.NORTH, flights.getFirst().side()); + assertEquals( + new AvenueStairwayLayout.Step(12, 63, -1), flights.getFirst().stairBlocks().getFirst()); + assertEquals(AvenueStairwayLayout.Side.SOUTH, flights.get(1).side()); + assertEquals( + new AvenueStairwayLayout.Step(12, 63, 71), flights.get(1).stairBlocks().getFirst()); + assertEquals( + new AvenueStairwayLayout.Step(48, 63, -1), flights.get(2).stairBlocks().getFirst()); + assertTrue(flights.stream().allMatch(flight -> flight.stairBlocks().getLast().y() == 76)); + } + @Test void rejectsAStairRunThatCannotFitWithinThePlotLength() { Cuboid plot = new Cuboid(2, 62, 2, 33, 71, 13); diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java index c2cff2f..4c4d8d3 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/RoadLayoutTest.java @@ -30,19 +30,30 @@ class RoadLayoutTest { @Test void buildsFullRoadWidthAndNonOverlappingCornersAtOnce() { - List road = RoadLayout.surroundingRoad(PLOT, 4); + List road = RoadLayout.surroundingRoad(PLOT, 4, 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))); + assertTrue(road.contains(new RoadLayout.BlockPosition(34, 2, RoadLayout.RoadType.AVENUE))); + assertTrue(road.contains(new RoadLayout.BlockPosition(2, 34, RoadLayout.RoadType.STREET))); + assertTrue( + road.contains(new RoadLayout.BlockPosition(34, 34, RoadLayout.RoadType.INTERSECTION))); } @Test void returnsNoRoadForZeroWidth() { - assertTrue(RoadLayout.surroundingRoad(PLOT, 0).isEmpty()); + assertTrue(RoadLayout.surroundingRoad(PLOT, 0, 0).isEmpty()); + } + + @Test + void usesIndependentWidthsForStreetAndAvenueBands() { + List road = RoadLayout.surroundingRoad(PLOT, 2, 4); + + assertEquals(416, road.size()); + assertTrue(road.contains(new RoadLayout.BlockPosition(2, 0, RoadLayout.RoadType.STREET))); + assertTrue(road.contains(new RoadLayout.BlockPosition(-2, 2, RoadLayout.RoadType.AVENUE))); + assertTrue( + road.contains(new RoadLayout.BlockPosition(-2, 0, RoadLayout.RoadType.INTERSECTION))); } } diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java index 6413a2d..9701202 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java @@ -26,30 +26,37 @@ import org.junit.jupiter.api.Test; class SpawnLayoutTest { @Test void defaultMultipliersPreserveTheOnePlotSpawnBounds() { - Cuboid spawn = SpawnLayout.bounds(1, 1, 1, 32, 10, 32, 4, 62); + Cuboid spawn = SpawnLayout.bounds(1, 1, 1, 32, 10, 32, 4, 4, 62); assertEqualsBounds(spawn, 2, 62, 2, 33, 71, 33); } @Test void appliesEachMultiplierToItsOwnAlignedGridDimension() { - Cuboid spawn = SpawnLayout.bounds(2, 3, 4, 32, 10, 32, 4, 62); + Cuboid spawn = SpawnLayout.bounds(2, 3, 4, 32, 10, 32, 4, 4, 62); assertEqualsBounds(spawn, 2, 62, 2, 69, 91, 141); } @Test void validatesTheEntireSpawnHeightAgainstWorldBounds() { - Cuboid spawn = SpawnLayout.bounds(1, 3, 1, 32, 10, 32, 4, 62); + Cuboid spawn = SpawnLayout.bounds(1, 3, 1, 32, 10, 32, 4, 4, 62); assertTrue(SpawnLayout.withinBuildHeight(spawn, -64, 320)); assertFalse(SpawnLayout.withinBuildHeight(spawn, -64, 91)); } + @Test + void alignsSpawnAxesToTheirIndependentRoadWidths() { + Cuboid spawn = SpawnLayout.bounds(2, 1, 3, 32, 10, 32, 6, 2, 62); + + assertEqualsBounds(spawn, 3, 62, 1, 72, 71, 100); + } + @Test void rejectsNonPositiveSpawnMultipliers() { assertThrows( - IllegalArgumentException.class, () -> SpawnLayout.bounds(0, 1, 1, 32, 10, 32, 4, 62)); + IllegalArgumentException.class, () -> SpawnLayout.bounds(0, 1, 1, 32, 10, 32, 4, 4, 62)); } private static void assertEqualsBounds( diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java index 8adffc2..c013fe0 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java @@ -82,6 +82,6 @@ class MetropolisHomeAcquireTest { assertEquals(4, MetropolisPlugin.normalizeMaxLevels(4)); assertEquals(3, MetropolisPlugin.clampMaxLevels(10, 3)); assertEquals(1, MetropolisPlugin.clampMaxLevels(2, 1)); - assertEquals(1, MetropolisPlugin.clampMaxLevels(0, 0)); + assertEquals(0, MetropolisPlugin.clampMaxLevels(0, 0)); } } From 60c3d41e5055a4bea4722378944e073a2ebcc488 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 08:21:22 -0700 Subject: [PATCH 08/10] Apply offsets to the logical plot grid --- Human-Tests.md | 4 +- README.md | 2 + .../metropolis/MetropolisPlugin.java | 71 ++++++----- .../metropolis/PlotGridLayout.java | 114 ++++++++++++++++++ .../bukkitplugins/metropolis/SpawnLayout.java | 10 +- .../metropolis/PlotGridLayoutTest.java | 55 +++++++++ .../metropolis/SpawnLayoutTest.java | 18 ++- 7 files changed, 234 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayout.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayoutTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 9121801..4ac2a5f 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -24,6 +24,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Set `plot.initial: 0` on a disposable server, restart, and join as a fresh player. Confirm no home is created until the player runs `/metropolis-home-acquire`; then verify the command creates the initial home and makes it active. - Join with a second new player. Confirm the new home is distinct, does not overlap the first home or spawn, and the City region grows to include it. - Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and upper plots are only allocated above footprints fully supported at every lower level. With a 1×1 spawn and a 2×2 home size, confirm a new home is allocated at base level rather than above the partially supported spawn. +- Set positive and negative `plot.offsetX`, `plot.offsetY`, and `plot.offsetZ` values on a disposable world. Verify plot regions, road bands, stair flights, and Spawn share the shifted grid origin; allocate multiple plots across negative coordinates and confirm logical-cell spacing stays consistent. Confirm changing offsets does not move existing saved regions; Metropolis-managed homes and reservations should retain their bounds and remain recognized. Treat automatic conversion of other saved regions as unverified. - Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to distinct positive values on a fresh disposable world. Confirm the Spawn region spans the corresponding plot-plus-road logical cells horizontally and the configured plot-height multiple vertically. Check blocks outside those bounds remain unchanged; configure a height exceeding the world's build limit and confirm startup rejects it clearly. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. @@ -76,10 +77,11 @@ 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 53 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 57 tests covering: - `AvenueStairwayLayoutTest`: centered per-segment street and avenue flights, both border directions, independent cadence, top-tread alignment, inverted backing plan, exact-width openings, and invalid-fit rejection. - `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. +- `PlotGridLayoutTest`: zero-offset compatibility, offsets on X/Y/Z, axis-specific spacing, and negative logical-index recovery. - `SpawnLayoutTest`: default one-cell bounds, independent multipliers, and build-height validation. - `AvenueStairwayBuilderTest`: configured stair material and facing/half data for avenue and street directions, upside-down backing stairs, exact upper-road AIR placements, and no block writes at lower-road Y. - `RoadLayoutTest`: independently sized street/avenue bands, correct road-type tagging, non-overlapping corners, and disabled roads at zero width. diff --git a/README.md b/README.md index 094f7e8..cae356d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p `plugins/Metropolis/config.yml` controls plot dimensions and limits, floor and support generation, signs, road width/level/material/clearance, spawn behavior, wall generation, world name, and per-player overrides. The per-level stack is configured with `plot.sizeY` and `plot.maxLevels`: `sizeY` is the full height of one plot level, and `maxLevels` is the number of stacked levels that can be allocated without exceeding the world build height. `plot.multiplier` sets the default plot-size multiplier; `plot.maxPerPlayer` sets the default home limit; `plot.initial` sets how many homes are auto-generated on join, and `0` requires `/metropolis-home-acquire` for the player's first home. +`plot.offsetX`, `plot.offsetY`, and `plot.offsetZ` shift the logical plot grid in blocks. Positive or negative offsets apply consistently to plot/road placement, vertical levels, spawn alignment, and logical-cell indexing. Changing offsets does not move existing saved WorldGuard regions or automatically convert an existing world. + Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to positive logical-grid multipliers. X and Z include the matching plot dimension plus road spacing; Y sets the spawn volume's height upward from `road.level` in multiples of `plot.sizeY`. All default to `1`. A newly created Spawn region must fit within the world's build height, and generation clears only within that region's configured vertical bounds. Existing saved Spawn regions retain their saved bounds. The `road.streets.*` and `road.avenues.*` sections independently configure width, level, material, clearance, supports, and stairs. Streets run along X; avenues run along Z. Each section's stair settings (`generate`, `material`, `width`, and `everyNBlocks`) use that section's road width and cadence. Street stair runs ascend along positive X at the north/south borders; avenue runs ascend along positive Z at the east/west borders. Runs are centered within each eligible logical plot-sized segment. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The three blocks before each top tread remain open only across the stair width. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 5a91661..87a4fb5 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -98,6 +98,9 @@ public class MetropolisPlugin extends JavaPlugin { private int plotSizeX = 24; private int plotSizeY = 256; private int plotSizeZ = 24; + private int plotOffsetX; + private int plotOffsetY; + private int plotOffsetZ; private int gridSizeX = 28; private int gridSizeY = 256; private int maxLevels = 1; @@ -146,6 +149,7 @@ public class MetropolisPlugin extends JavaPlugin { private Cuboid _cityCuboid = null; private ProtectedRegion _spawnRegion = null; private ProtectedRegion _cityRegion = null; + private PlotGridLayout plotGridLayout; @Override public void onDisable() { @@ -201,6 +205,9 @@ public class MetropolisPlugin extends JavaPlugin { plotSizeX = safeGetIntFromConfig(config, "plot.sizeX"); plotSizeY = optionalIntFromConfig(config, "plot.sizeY", 256); plotSizeZ = safeGetIntFromConfig(config, "plot.sizeZ"); + plotOffsetX = safeGetIntFromConfig(config, "plot.offsetX"); + plotOffsetY = safeGetIntFromConfig(config, "plot.offsetY"); + plotOffsetZ = safeGetIntFromConfig(config, "plot.offsetZ"); maxLevels = optionalIntFromConfig(config, "plot.maxLevels", "maxLevels", 1); generateFloor = safeGetBooleanFromConfig(config, "plot.floor.generate"); floorMaterial = safeGetMaterialFromConfig(config, "plot.floor.material"); @@ -329,9 +336,20 @@ public class MetropolisPlugin extends JavaPlugin { if (streetWidth < 0 || avenueWidth < 0) { throw new IllegalArgumentException("Road widths must not be negative."); } - gridSizeX = plotSizeX + avenueWidth; - gridSizeY = Math.max(plotSizeY + Math.max(streetWidth, avenueWidth), 1); - gridSizeZ = plotSizeZ + streetWidth; + plotGridLayout = + new PlotGridLayout( + plotSizeX, + plotSizeY, + plotSizeZ, + streetWidth, + avenueWidth, + plotOffsetX, + plotOffsetY, + plotOffsetZ, + roadLevel); + gridSizeX = plotGridLayout.gridSizeX(); + gridSizeY = plotGridLayout.gridSizeY(); + gridSizeZ = plotGridLayout.gridSizeZ(); regionManager = WorldGuard.getInstance().getPlatform().getRegionContainer().get(BukkitAdapter.adapt(world)); @@ -622,7 +640,10 @@ public class MetropolisPlugin extends JavaPlugin { plotSizeZ, avenueWidth, streetWidth, - roadLevel); + roadLevel, + plotOffsetX, + plotOffsetY, + plotOffsetZ); if (!SpawnLayout.withinBuildHeight(spawnCuboid, world.getMinHeight(), world.getMaxHeight())) { getLogger() .severe( @@ -1037,11 +1058,7 @@ public class MetropolisPlugin extends JavaPlugin { } public BlockVector3 getPlotMin(int row, int col, int plotMultiplier, int level) { - BlockVector3 gridMin = getGridMin(row, col, plotMultiplier, level); - - BlockVector3 bv = - BlockVector3.at( - gridMin.x() + avenueWidth / 2, getLevelStartY(level), gridMin.z() + streetWidth / 2); + BlockVector3 bv = plotGridLayout.plotMin(row, col, level); getLogger().info(String.format("getPlotMin (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } @@ -1051,13 +1068,7 @@ public class MetropolisPlugin extends JavaPlugin { } public BlockVector3 getPlotMax(int row, int col, int plotMultiplier, int level) { - BlockVector3 gridMax = getGridMax(row, col, plotMultiplier, level); - - BlockVector3 bv = - BlockVector3.at( - gridMax.x() - (avenueWidth - avenueWidth / 2), - getLevelStartY(level) + plotSizeY - 1, - gridMax.z() - (streetWidth - streetWidth / 2)); + BlockVector3 bv = plotGridLayout.plotMax(row, col, plotMultiplier, plotMultiplier, level); getLogger().info(String.format("getPlotMax (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } @@ -1067,7 +1078,7 @@ public class MetropolisPlugin extends JavaPlugin { } public BlockVector3 getGridMin(int row, int col, int plotMultiplier, int level) { - BlockVector3 bv = BlockVector3.at(col * gridSizeX, getLevelStartY(level), row * gridSizeZ); + BlockVector3 bv = plotGridLayout.gridMin(row, col, level); getLogger().info(String.format("getGridMin (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } @@ -1077,31 +1088,29 @@ public class MetropolisPlugin extends JavaPlugin { } public BlockVector3 getGridMax(int row, int col, int plotMultiplier, int level) { - BlockVector3 bv = - BlockVector3.at( - (col + plotMultiplier) * gridSizeX - 1, - getLevelStartY(level) + plotSizeY - 1, - (row + plotMultiplier) * gridSizeZ - 1); + BlockVector3 bv = plotGridLayout.gridMax(row, col, plotMultiplier, plotMultiplier, level); getLogger().info(String.format("getGridMax (%d, %d, %d)", bv.x(), bv.y(), bv.z())); return bv; } private int getPlotXFromMin(Cuboid cuboid) { - return (cuboid.minX - avenueWidth / 2) / gridSizeX; + return plotGridLayout.plotXIndexFromMin(cuboid); } private int getPlotZFromMin(Cuboid cuboid) { - return (cuboid.minZ - streetWidth / 2) / gridSizeZ; + return plotGridLayout.plotZIndexFromMin(cuboid); } private int getLevelPitchY() { - return plotSizeY + Math.max(streetWidth, avenueWidth); + return plotGridLayout.gridSizeY(); } private int getEffectiveMaxLevels() { - int availableStreetLevels = getAvailableLevelsAt(roadLevel); + int streetBaseY = getLevelStartY(0); + int avenueBaseY = streetBaseY + avenueLevel - roadLevel; + int availableStreetLevels = getAvailableLevelsAt(streetBaseY); int availableAvenueLevels = - avenueWidth > 0 ? getAvailableLevelsAt(avenueLevel) : availableStreetLevels; + avenueWidth > 0 ? getAvailableLevelsAt(avenueBaseY) : availableStreetLevels; int maxWorldLevels = Math.min(availableStreetLevels, availableAvenueLevels); return clampMaxLevels(maxLevels, maxWorldLevels); } @@ -1119,7 +1128,7 @@ public class MetropolisPlugin extends JavaPlugin { } private int getLevelStartY(int level) { - return roadLevel + level * getLevelPitchY(); + return plotGridLayout.levelStartY(level); } private void setHomeOccupied( @@ -1289,7 +1298,7 @@ public class MetropolisPlugin extends JavaPlugin { private List getUpperRoadOpeningsForPlot( Cuboid plotCuboid, int roadY, RoadLayout.RoadType roadType) { - int baseY = roadType == RoadLayout.RoadType.AVENUE ? avenueLevel : roadLevel; + int baseY = (roadType == RoadLayout.RoadType.AVENUE ? avenueLevel : roadLevel) + plotOffsetY; if (!areStairsEnabled(roadType) || roadY <= baseY) { return List.of(); } @@ -1325,7 +1334,7 @@ public class MetropolisPlugin extends JavaPlugin { avenueStairsEveryNBlocks, avenueWidth, avenueStairWidth, - avenueLevel + lowerLevel * getLevelPitchY(), + avenueLevel + plotOffsetY + lowerLevel * getLevelPitchY(), getLevelPitchY(), 3); } @@ -1338,7 +1347,7 @@ public class MetropolisPlugin extends JavaPlugin { streetStairsEveryNBlocks, streetWidth, streetStairWidth, - roadLevel + lowerLevel * getLevelPitchY(), + roadLevel + plotOffsetY + lowerLevel * getLevelPitchY(), getLevelPitchY(), 3); } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayout.java new file mode 100644 index 0000000..ba5b102 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayout.java @@ -0,0 +1,114 @@ +/* +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 com.sk89q.worldedit.math.BlockVector3; + +public final class PlotGridLayout { + private final int plotSizeX; + private final int plotSizeY; + private final int plotSizeZ; + private final int streetWidth; + private final int avenueWidth; + private final int offsetX; + private final int offsetY; + private final int offsetZ; + private final int roadLevel; + private final int gridSizeX; + private final int gridSizeY; + private final int gridSizeZ; + + public PlotGridLayout( + int plotSizeX, + int plotSizeY, + int plotSizeZ, + int streetWidth, + int avenueWidth, + int offsetX, + int offsetY, + int offsetZ, + int roadLevel) { + if (plotSizeX < 1 || plotSizeY < 1 || plotSizeZ < 1 || streetWidth < 0 || avenueWidth < 0) { + throw new IllegalArgumentException( + "Plot dimensions must be positive and road widths nonnegative"); + } + + this.plotSizeX = plotSizeX; + this.plotSizeY = plotSizeY; + this.plotSizeZ = plotSizeZ; + this.streetWidth = streetWidth; + this.avenueWidth = avenueWidth; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.offsetZ = offsetZ; + this.roadLevel = roadLevel; + gridSizeX = Math.addExact(plotSizeX, avenueWidth); + gridSizeY = Math.addExact(plotSizeY, Math.max(streetWidth, avenueWidth)); + gridSizeZ = Math.addExact(plotSizeZ, streetWidth); + } + + public BlockVector3 gridMin(int row, int col, int level) { + return BlockVector3.at( + offsetX + col * gridSizeX, levelStartY(level), offsetZ + row * gridSizeZ); + } + + public BlockVector3 gridMax(int row, int col, int multiplierX, int multiplierZ, int level) { + return BlockVector3.at( + offsetX + (col + multiplierX) * gridSizeX - 1, + levelStartY(level) + plotSizeY - 1, + offsetZ + (row + multiplierZ) * gridSizeZ - 1); + } + + public BlockVector3 plotMin(int row, int col, int level) { + BlockVector3 gridMin = gridMin(row, col, level); + return BlockVector3.at( + gridMin.x() + avenueWidth / 2, gridMin.y(), gridMin.z() + streetWidth / 2); + } + + public BlockVector3 plotMax(int row, int col, int multiplierX, int multiplierZ, int level) { + BlockVector3 gridMax = gridMax(row, col, multiplierX, multiplierZ, level); + return BlockVector3.at( + gridMax.x() - (avenueWidth - avenueWidth / 2), + gridMax.y(), + gridMax.z() - (streetWidth - streetWidth / 2)); + } + + public int plotXIndexFromMin(Cuboid plot) { + return Math.floorDiv(plot.minX - offsetX - avenueWidth / 2, gridSizeX); + } + + public int plotZIndexFromMin(Cuboid plot) { + return Math.floorDiv(plot.minZ - offsetZ - streetWidth / 2, gridSizeZ); + } + + public int levelStartY(int level) { + return roadLevel + offsetY + level * gridSizeY; + } + + public int gridSizeX() { + return gridSizeX; + } + + public int gridSizeY() { + return gridSizeY; + } + + public int gridSizeZ() { + return gridSizeZ; + } +} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java index 1365dc4..302da2c 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayout.java @@ -29,7 +29,10 @@ public final class SpawnLayout { int plotSizeZ, int avenueWidth, int streetWidth, - int roadLevel) { + int roadLevel, + int offsetX, + int offsetY, + int offsetZ) { if (sizeX < 1 || sizeY < 1 || sizeZ < 1 || avenueWidth < 0 || streetWidth < 0) { throw new IllegalArgumentException( "Spawn multipliers must be positive and road widths nonnegative"); @@ -42,10 +45,11 @@ public final class SpawnLayout { int maxInsetX = avenueWidth - minX; int maxInsetZ = streetWidth - minZ; int maxX = Math.subtractExact(Math.multiplyExact(sizeX, gridSizeX) - 1, maxInsetX); - int maxY = Math.addExact(roadLevel, Math.multiplyExact(sizeY, plotSizeY) - 1); + int baseY = Math.addExact(roadLevel, offsetY); + int maxY = Math.addExact(baseY, Math.multiplyExact(sizeY, plotSizeY) - 1); int maxZ = Math.subtractExact(Math.multiplyExact(sizeZ, gridSizeZ) - 1, maxInsetZ); - return new Cuboid(minX, roadLevel, minZ, maxX, maxY, maxZ); + return new Cuboid(offsetX + minX, baseY, offsetZ + minZ, offsetX + maxX, maxY, offsetZ + maxZ); } public static boolean withinBuildHeight(Cuboid cuboid, int minHeight, int maxHeight) { diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayoutTest.java new file mode 100644 index 0000000..e8238c5 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotGridLayoutTest.java @@ -0,0 +1,55 @@ +/* +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 com.sk89q.worldedit.math.BlockVector3; + +import org.junit.jupiter.api.Test; + +class PlotGridLayoutTest { + @Test + void zeroOffsetsPreserveTheExistingPlotGrid() { + PlotGridLayout grid = new PlotGridLayout(32, 10, 32, 4, 4, 0, 0, 0, 62); + + assertEquals(BlockVector3.at(0, 62, 0), grid.gridMin(0, 0, 0)); + assertEquals(BlockVector3.at(35, 71, 35), grid.gridMax(0, 0, 1, 1, 0)); + assertEquals(BlockVector3.at(2, 62, 2), grid.plotMin(0, 0, 0)); + assertEquals(BlockVector3.at(33, 71, 33), grid.plotMax(0, 0, 1, 1, 0)); + } + + @Test + void offsetsShiftGridAndPlotsOnAllAxesIncludingHigherLevels() { + PlotGridLayout grid = new PlotGridLayout(32, 10, 32, 2, 6, -7, 3, 11, 62); + + assertEquals(BlockVector3.at(-45, 97, -23), grid.gridMin(-1, -1, 2)); + assertEquals(BlockVector3.at(30, 106, 44), grid.gridMax(-1, -1, 2, 2, 2)); + assertEquals(BlockVector3.at(-42, 97, -22), grid.plotMin(-1, -1, 2)); + assertEquals(BlockVector3.at(27, 106, 43), grid.plotMax(-1, -1, 2, 2, 2)); + } + + @Test + void mapsNegativePlotBoundsBackToTheirLogicalIndices() { + PlotGridLayout grid = new PlotGridLayout(32, 10, 32, 2, 6, -7, 3, 11, 62); + Cuboid plot = new Cuboid(grid.plotMin(-1, -1, 0), grid.plotMax(-1, -1, 2, 2, 0)); + + assertEquals(-1, grid.plotXIndexFromMin(plot)); + assertEquals(-1, grid.plotZIndexFromMin(plot)); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java index 9701202..c23cece 100644 --- a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/SpawnLayoutTest.java @@ -26,21 +26,21 @@ import org.junit.jupiter.api.Test; class SpawnLayoutTest { @Test void defaultMultipliersPreserveTheOnePlotSpawnBounds() { - Cuboid spawn = SpawnLayout.bounds(1, 1, 1, 32, 10, 32, 4, 4, 62); + Cuboid spawn = SpawnLayout.bounds(1, 1, 1, 32, 10, 32, 4, 4, 62, 0, 0, 0); assertEqualsBounds(spawn, 2, 62, 2, 33, 71, 33); } @Test void appliesEachMultiplierToItsOwnAlignedGridDimension() { - Cuboid spawn = SpawnLayout.bounds(2, 3, 4, 32, 10, 32, 4, 4, 62); + Cuboid spawn = SpawnLayout.bounds(2, 3, 4, 32, 10, 32, 4, 4, 62, 0, 0, 0); assertEqualsBounds(spawn, 2, 62, 2, 69, 91, 141); } @Test void validatesTheEntireSpawnHeightAgainstWorldBounds() { - Cuboid spawn = SpawnLayout.bounds(1, 3, 1, 32, 10, 32, 4, 4, 62); + Cuboid spawn = SpawnLayout.bounds(1, 3, 1, 32, 10, 32, 4, 4, 62, 0, 0, 0); assertTrue(SpawnLayout.withinBuildHeight(spawn, -64, 320)); assertFalse(SpawnLayout.withinBuildHeight(spawn, -64, 91)); @@ -48,15 +48,23 @@ class SpawnLayoutTest { @Test void alignsSpawnAxesToTheirIndependentRoadWidths() { - Cuboid spawn = SpawnLayout.bounds(2, 1, 3, 32, 10, 32, 6, 2, 62); + Cuboid spawn = SpawnLayout.bounds(2, 1, 3, 32, 10, 32, 6, 2, 62, 0, 0, 0); assertEqualsBounds(spawn, 3, 62, 1, 72, 71, 100); } + @Test + void alignsSpawnBoundsWithAllGridOffsets() { + Cuboid spawn = SpawnLayout.bounds(2, 2, 3, 32, 10, 32, 6, 2, 62, -7, 3, 11); + + assertEqualsBounds(spawn, -4, 65, 12, 65, 84, 111); + } + @Test void rejectsNonPositiveSpawnMultipliers() { assertThrows( - IllegalArgumentException.class, () -> SpawnLayout.bounds(0, 1, 1, 32, 10, 32, 4, 4, 62)); + IllegalArgumentException.class, + () -> SpawnLayout.bounds(0, 1, 1, 32, 10, 32, 4, 4, 62, 0, 0, 0)); } private static void assertEqualsBounds( From 99f6195542933721eea06dc63766d8391f5931bb Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 08:24:03 -0700 Subject: [PATCH 09/10] Avoid saved WorldGuard regions during allocation --- Human-Tests.md | 4 +- README.md | 2 + .../metropolis/MetropolisPlugin.java | 10 +++- .../metropolis/WorldGuardRegionOccupancy.java | 44 ++++++++++++++ .../WorldGuardRegionOccupancyTest.java | 60 +++++++++++++++++++ 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancy.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancyTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 4ac2a5f..4764672 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -28,6 +28,7 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to distinct positive values on a fresh disposable world. Confirm the Spawn region spans the corresponding plot-plus-road logical cells horizontally and the configured plot-height multiple vertically. Check blocks outside those bounds remain unchanged; configure a height exceeding the world's build limit and confirm startup rejects it clearly. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. +- Create an unrelated saved WorldGuard region in the next candidate area, then allocate a home and confirm it is skipped. Repeat with a non-cuboid region and verify the full bounding cuboid is avoided; confirm `City` itself does not prevent allocation. - Test a user override that grants a different plot multiplier or home limit. Confirm the generated bounds and allowed number of homes match the override, and that the configured global defaults still apply to other players. ### Generated blocks and geometry @@ -77,11 +78,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 57 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 58 tests covering: - `AvenueStairwayLayoutTest`: centered per-segment street and avenue flights, both border directions, independent cadence, top-tread alignment, inverted backing plan, exact-width openings, and invalid-fit rejection. - `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. - `PlotGridLayoutTest`: zero-offset compatibility, offsets on X/Y/Z, axis-specific spacing, and negative logical-index recovery. +- `WorldGuardRegionOccupancyTest`: arbitrary saved-region bounds are excluded while City/global regions are treated as non-occupying envelopes. - `SpawnLayoutTest`: default one-cell bounds, independent multipliers, and build-height validation. - `AvenueStairwayBuilderTest`: configured stair material and facing/half data for avenue and street directions, upside-down backing stairs, exact upper-road AIR placements, and no block writes at lower-road Y. - `RoadLayoutTest`: independently sized street/avenue bands, correct road-type tagging, non-overlapping corners, and disabled roads at zero width. diff --git a/README.md b/README.md index cae356d..34c6f99 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p `plot.offsetX`, `plot.offsetY`, and `plot.offsetZ` shift the logical plot grid in blocks. Positive or negative offsets apply consistently to plot/road placement, vertical levels, spawn alignment, and logical-cell indexing. Changing offsets does not move existing saved WorldGuard regions or automatically convert an existing world. +Before allocating, Metropolis treats every saved WorldGuard region except the enclosing `City` and special `__global__` region as occupied. It uses each region's bounding cuboid; for non-cuboid regions this can conservatively leave extra gaps to ensure new plot regions do not overlap them. + Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to positive logical-grid multipliers. X and Z include the matching plot dimension plus road spacing; Y sets the spawn volume's height upward from `road.level` in multiples of `plot.sizeY`. All default to `1`. A newly created Spawn region must fit within the world's build height, and generation clears only within that region's configured vertical bounds. Existing saved Spawn regions retain their saved bounds. The `road.streets.*` and `road.avenues.*` sections independently configure width, level, material, clearance, supports, and stairs. Streets run along X; avenues run along Z. Each section's stair settings (`generate`, `material`, `width`, and `everyNBlocks`) use that section's road width and cadence. Street stair runs ascend along positive X at the north/south borders; avenue runs ascend along positive Z at the east/west borders. Runs are centered within each eligible logical plot-sized segment. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The three blocks before each top tread remain open only across the stair width. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 87a4fb5..9b3ec35 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -90,6 +90,7 @@ public class MetropolisPlugin extends JavaPlugin { private HashMap _userOverrides; private List _permissionOverrides; private HashMap _currentHomes; + private List _savedWorldGuardBounds = List.of(); private PlayerJoinListener _playerJoinListener = null; @@ -891,7 +892,7 @@ public class MetropolisPlugin extends JavaPlugin { return true; } - return false; + return WorldGuardRegionOccupancy.overlapsAny(cuboid, _savedWorldGuardBounds); } private boolean areBlocksOccupied(int row, int col, int i) { @@ -911,6 +912,8 @@ public class MetropolisPlugin extends JavaPlugin { } private Cuboid findNextUnownedHomeRegion(int plotMultiplier) { + refreshSavedWorldGuardBounds(); + int row = 0; int col = 0; int ring = 0; @@ -995,6 +998,11 @@ public class MetropolisPlugin extends JavaPlugin { return null; } + private void refreshSavedWorldGuardBounds() { + _savedWorldGuardBounds = + WorldGuardRegionOccupancy.boundsToAvoid(regionManager.getRegions().values()); + } + private boolean isAvailableSupportedPlot(int row, int col, int plotMultiplier, int level) { return !areBlocksOccupied(row, col, plotMultiplier, level) && PlotLevelSupport.hasCompleteSupport( diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancy.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancy.java new file mode 100644 index 0000000..4ad72e2 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancy.java @@ -0,0 +1,44 @@ +/* +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.Collection; +import java.util.List; + +import com.sk89q.worldguard.protection.regions.ProtectedRegion; + +public final class WorldGuardRegionOccupancy { + private WorldGuardRegionOccupancy() {} + + public static List boundsToAvoid(Collection regions) { + List bounds = new ArrayList<>(); + for (ProtectedRegion region : regions) { + String id = region.getId(); + if (id.equalsIgnoreCase("city") || id.equalsIgnoreCase("__global__")) { + continue; + } + bounds.add(new Cuboid(region.getMinimumPoint(), region.getMaximumPoint())); + } + return List.copyOf(bounds); + } + + public static boolean overlapsAny(Cuboid candidate, Collection occupiedBounds) { + return occupiedBounds.stream().anyMatch(candidate::intersects); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancyTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancyTest.java new file mode 100644 index 0000000..ded55f0 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/WorldGuardRegionOccupancyTest.java @@ -0,0 +1,60 @@ +/* +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; + +import org.junit.jupiter.api.Test; + +class WorldGuardRegionOccupancyTest { + @Test + void treatsArbitrarySavedRegionBoundsAsOccupiedButIgnoresCityAndGlobalRegions() { + ProtectedRegion market = + region("market", BlockVector3.at(40, 62, 40), BlockVector3.at(70, 90, 70)); + ProtectedRegion city = + region("City", BlockVector3.at(-100, 0, -100), BlockVector3.at(100, 320, 100)); + ProtectedRegion global = + region( + "__global__", + BlockVector3.at(-30_000_000, -64, -30_000_000), + BlockVector3.at(30_000_000, 320, 30_000_000)); + + List occupied = WorldGuardRegionOccupancy.boundsToAvoid(List.of(market, city, global)); + + assertEquals(1, occupied.size()); + assertTrue(WorldGuardRegionOccupancy.overlapsAny(new Cuboid(60, 70, 60, 80, 75, 80), occupied)); + assertFalse(WorldGuardRegionOccupancy.overlapsAny(new Cuboid(0, 62, 0, 31, 71, 31), occupied)); + } + + private static ProtectedRegion region(String id, BlockVector3 minimum, BlockVector3 maximum) { + ProtectedRegion region = mock(ProtectedRegion.class); + when(region.getId()).thenReturn(id); + when(region.getMinimumPoint()).thenReturn(minimum); + when(region.getMaximumPoint()).thenReturn(maximum); + return region; + } +} From 4e459ec9fa71338a85ac9a53445e73c88ec35f9f Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 27 Sep 2026 08:40:04 -0700 Subject: [PATCH 10/10] Expand City and bound plot search --- Human-Tests.md | 6 +- README.md | 4 ++ TODO.md | 4 +- .../metropolis/CityRegionBounds.java | 43 ++++++++++++++ .../metropolis/MetropolisPlugin.java | 56 +++++++++++++----- .../metropolis/PlotWorldBorderBounds.java | 59 +++++++++++++++++++ .../metropolis/CityRegionBoundsTest.java | 44 ++++++++++++++ .../metropolis/PlotWorldBorderBoundsTest.java | 45 ++++++++++++++ 8 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBounds.java create mode 100644 src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBounds.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBoundsTest.java create mode 100644 src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBoundsTest.java diff --git a/Human-Tests.md b/Human-Tests.md index 4764672..ec7cf6f 100644 --- a/Human-Tests.md +++ b/Human-Tests.md @@ -24,11 +24,13 @@ Use this checklist for behavior that depends on a real Paper world, player inter - Set `plot.initial: 0` on a disposable server, restart, and join as a fresh player. Confirm no home is created until the player runs `/metropolis-home-acquire`; then verify the command creates the initial home and makes it active. - Join with a second new player. Confirm the new home is distinct, does not overlap the first home or spawn, and the City region grows to include it. - Start from a disposable world configured with `plot.sizeY` and `plot.maxLevels` from the multi-level example. Confirm the level count is honored, the city does not exceed the world's build height, and upper plots are only allocated above footprints fully supported at every lower level. With a 1×1 spawn and a 2×2 home size, confirm a new home is allocated at base level rather than above the partially supported spawn. +- Generate homes at base and elevated levels, then verify each full home cuboid is inside the WorldGuard `City` region. Add a home/reservation extending the city bounds and confirm City expands without shrinking its existing bounds or flags. - Set positive and negative `plot.offsetX`, `plot.offsetY`, and `plot.offsetZ` values on a disposable world. Verify plot regions, road bands, stair flights, and Spawn share the shifted grid origin; allocate multiple plots across negative coordinates and confirm logical-cell spacing stays consistent. Confirm changing offsets does not move existing saved regions; Metropolis-managed homes and reservations should retain their bounds and remain recognized. Treat automatic conversion of other saved regions as unverified. - Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to distinct positive values on a fresh disposable world. Confirm the Spawn region spans the corresponding plot-plus-road logical cells horizontally and the configured plot-height multiple vertically. Check blocks outside those bounds remain unchanged; configure a height exceeding the world's build limit and confirm startup rejects it clearly. - Join again as an existing player. Confirm the same home is selected and no duplicate region or plot is created. - Reserve an area before allocating another home. Confirm future allocations skip it. Repeat with reservations near the city edge and near existing roads to look for overlaps or gaps. - Create an unrelated saved WorldGuard region in the next candidate area, then allocate a home and confirm it is skipped. Repeat with a non-cuboid region and verify the full bounding cuboid is avoided; confirm `City` itself does not prevent allocation. +- Set a small WorldBorder in a disposable world, occupy or reserve the remaining valid grid cells, and request another home. Confirm search grows beyond 256 rings but never places a plot/road footprint outside the border; when no supported cell remains, check the error log and confirm no region is created. - Test a user override that grants a different plot multiplier or home limit. Confirm the generated bounds and allowed number of homes match the override, and that the configured global defaults still apply to other players. ### Generated blocks and geometry @@ -78,12 +80,14 @@ 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 58 tests covering: +Run `./gradlew test` for the server-free JUnit suite. It has 62 tests covering: - `AvenueStairwayLayoutTest`: centered per-segment street and avenue flights, both border directions, independent cadence, top-tread alignment, inverted backing plan, exact-width openings, and invalid-fit rejection. - `PlotLevelSupportTest`: rejection of upper plots with missing logical support cells and complete coverage requirements across all lower levels. +- `CityRegionBoundsTest`: City bounds expand to include elevated plots and Spawn without shrinking existing protection bounds. - `PlotGridLayoutTest`: zero-offset compatibility, offsets on X/Y/Z, axis-specific spacing, and negative logical-index recovery. - `WorldGuardRegionOccupancyTest`: arbitrary saved-region bounds are excluded while City/global regions are treated as non-occupying envelopes. +- `PlotWorldBorderBoundsTest`: search bounds follow WorldBorder size/center and require the full candidate footprint inside it. - `SpawnLayoutTest`: default one-cell bounds, independent multipliers, and build-height validation. - `AvenueStairwayBuilderTest`: configured stair material and facing/half data for avenue and street directions, upside-down backing stairs, exact upper-road AIR placements, and no block writes at lower-road Y. - `RoadLayoutTest`: independently sized street/avenue bands, correct road-type tagging, non-overlapping corners, and disabled roads at zero width. diff --git a/README.md b/README.md index 34c6f99..e6121ae 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,16 @@ Copy `.env.example` to `.env` and configure the Paper version/build and server p Before allocating, Metropolis treats every saved WorldGuard region except the enclosing `City` and special `__global__` region as occupied. It uses each region's bounding cuboid; for non-cuboid regions this can conservatively leave extra gaps to ensure new plot regions do not overlap them. +Plot allocation continues outward up to the configured WorldBorder. The complete logical plot-and-road footprint must fit inside the border; if no supported, unoccupied location remains, Metropolis logs an error and creates no region. + Set `spawn.sizeX`, `spawn.sizeY`, and `spawn.sizeZ` to positive logical-grid multipliers. X and Z include the matching plot dimension plus road spacing; Y sets the spawn volume's height upward from `road.level` in multiples of `plot.sizeY`. All default to `1`. A newly created Spawn region must fit within the world's build height, and generation clears only within that region's configured vertical bounds. Existing saved Spawn regions retain their saved bounds. The `road.streets.*` and `road.avenues.*` sections independently configure width, level, material, clearance, supports, and stairs. Streets run along X; avenues run along Z. Each section's stair settings (`generate`, `material`, `width`, and `everyNBlocks`) use that section's road width and cadence. Street stair runs ascend along positive X at the north/south borders; avenue runs ascend along positive Z at the east/west borders. Runs are centered within each eligible logical plot-sized segment. The lower road remains the normal-material landing; stairs begin one block above and forward from it. Connected following treads use upside-down stairs facing back down the run as backing, and the final tread reaches the upper road Y. The three blocks before each top tread remain open only across the stair width. Upper-level plots require complete generated support below: every logical plot cell in their footprint must be occupied by a generated home or spawn at every lower level. If a footprint has gaps below, allocation skips that upper candidate and keeps searching for a supported lower-level location. +The WorldGuard `City` region expands in all three dimensions to contain its previous bounds, the requested city footprint, Spawn, and generated homes/reservations. New upper-level plots therefore remain inside City protection; adding a home or reservation also triggers a resize. + 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. diff --git a/TODO.md b/TODO.md index afdff84..151c894 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,7 @@ | GIT-009 | Planning | i have three reserve plots and one just got overwritten when a new player joined. | | GIT-008 | Ready | Implement multiple plot sizes by granting multiple plots around each other. | GIT-007 | Planning | Allow buying and selling of homes via an ecnoomy plugin. | -| GIT-006 | Ready | Add levels support. | +| GIT-006 | Done | Add levels support. | | GIT-005 | Planning | Add the possibility to create multiple plot homes in shapes. | | GIT-004 | Done | Add a command to request home allocation. | | GIT-003 | Ready | Add support for multiple homes. | @@ -86,7 +86,7 @@ Add the possibility to create multiple plot homes in contiguous shapes. * Sphere closest to a point. # GIT-006 - Add levels support. -**Status:** Ready +**Status:** Done **Description:** Add multiple vertical levels to the city. Use the existing `road.level` as the base Y: the bottom of a ground-level plot is at that Y, and `plot.sizeY` defines the full plot height. For example, a five-block-high plot includes its floor at road level, three usable blocks, and a roof block; all five blocks belong to the plot region. Use the same plot-dimension-plus-road-spacing grid pitch vertically as horizontally; when road width is zero, adjacent plot volumes may touch. Do not allocate a plot if its full extent would exceed the world's buildable area. If the search cannot find any valid plot, log an allocation error and do not create the plot or region. The current config example is not intended for a multilevel world; request an updated `config.yml` before implementation. Existing `plot.offsetX`, `plot.offsetY`, and `plot.offsetZ` nudge the grid; `plot.multiplier`, `userOverrides[username].plotMultiplier`, and future `permissionOverrides[permission_name].plotMultiplier` affect plot size. diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBounds.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBounds.java new file mode 100644 index 0000000..77061e5 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBounds.java @@ -0,0 +1,43 @@ +/* +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; + +public final class CityRegionBounds { + private CityRegionBounds() {} + + public static Cuboid expandToContain( + Cuboid existing, Cuboid requested, Iterable occupiedRegions) { + int minX = Math.min(existing.minX, requested.minX); + int minY = Math.min(existing.minY, requested.minY); + int minZ = Math.min(existing.minZ, requested.minZ); + int maxX = Math.max(existing.maxX, requested.maxX); + int maxY = Math.max(existing.maxY, requested.maxY); + int maxZ = Math.max(existing.maxZ, requested.maxZ); + + for (Cuboid occupied : occupiedRegions) { + minX = Math.min(minX, occupied.minX); + minY = Math.min(minY, occupied.minY); + minZ = Math.min(minZ, occupied.minZ); + maxX = Math.max(maxX, occupied.maxX); + maxY = Math.max(maxY, occupied.maxY); + maxZ = Math.max(maxZ, occupied.maxZ); + } + + return new Cuboid(minX, minY, minZ, maxX, maxY, maxZ); + } +} diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 9b3ec35..166f517 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -209,7 +209,7 @@ public class MetropolisPlugin extends JavaPlugin { plotOffsetX = safeGetIntFromConfig(config, "plot.offsetX"); plotOffsetY = safeGetIntFromConfig(config, "plot.offsetY"); plotOffsetZ = safeGetIntFromConfig(config, "plot.offsetZ"); - maxLevels = optionalIntFromConfig(config, "plot.maxLevels", "maxLevels", 1); + maxLevels = optionalIntFromConfig(config, "plot.maxLevels", 1); generateFloor = safeGetBooleanFromConfig(config, "plot.floor.generate"); floorMaterial = safeGetMaterialFromConfig(config, "plot.floor.material"); spaceAboveFloor = safeGetIntFromConfig(config, "plot.floor.clearSpaceAbove"); @@ -919,14 +919,26 @@ public class MetropolisPlugin extends JavaPlugin { int ring = 0; int min = -ring; int max = ring - (plotMultiplier - 1); - boolean done = false; int levelCount = getEffectiveMaxLevels(); if (levelCount == 0) { getLogger().severe("No configured plot level fits within the world's build height."); return null; } - while (!done) { + var worldBorder = world.getWorldBorder(); + Location borderCenter = worldBorder.getCenter(); + int maxSearchRing = + PlotWorldBorderBounds.maximumSearchRing( + worldBorder.getSize(), + borderCenter.getX(), + borderCenter.getZ(), + plotOffsetX, + plotOffsetZ, + gridSizeX, + gridSizeZ, + plotMultiplier); + + while (ring <= maxSearchRing) { row = min; col = min; @@ -988,13 +1000,9 @@ public class MetropolisPlugin extends JavaPlugin { ring++; min = -ring; max = ring - (plotMultiplier - 1); - if (ring > 256) { - done = true; - } } - getLogger() - .severe("Unable to find an available supported plot within the allocation search area."); + getLogger().severe("Unable to find an available supported plot before the world border."); return null; } @@ -1004,7 +1012,17 @@ public class MetropolisPlugin extends JavaPlugin { } private boolean isAvailableSupportedPlot(int row, int col, int plotMultiplier, int level) { - return !areBlocksOccupied(row, col, plotMultiplier, level) + Location borderCenter = world.getWorldBorder().getCenter(); + Cuboid candidateGrid = + new Cuboid( + getGridMin(row, col, plotMultiplier, level), + getGridMax(row, col, plotMultiplier, level)); + return PlotWorldBorderBounds.contains( + candidateGrid, + world.getWorldBorder().getSize(), + borderCenter.getX(), + borderCenter.getZ()) + && !areBlocksOccupied(row, col, plotMultiplier, level) && PlotLevelSupport.hasCompleteSupport( row, col, plotMultiplier, level, this::isGeneratedLogicalBlock); } @@ -1027,11 +1045,19 @@ public class MetropolisPlugin extends JavaPlugin { if (cityRegion instanceof ProtectedCuboidRegion) { ProtectedCuboidRegion region = (ProtectedCuboidRegion) cityRegion; - BlockVector3 min; - BlockVector3 max; - - min = getPlotMin(-size / 2, -size / 2, 1); - max = getPlotMax(size / 2, size / 2, 1); + Cuboid requestedCityBounds = + new Cuboid(getPlotMin(-size / 2, -size / 2, 1), getPlotMax(size / 2, size / 2, 1)); + List occupiedBounds = new ArrayList<>(); + for (Plot plot : _occupiedPlots) { + occupiedBounds.add(plot.getCuboid()); + } + if (_spawnCuboid != null) { + occupiedBounds.add(_spawnCuboid); + } + Cuboid cityBounds = + CityRegionBounds.expandToContain(_cityCuboid, requestedCityBounds, occupiedBounds); + BlockVector3 min = cityBounds.getMin(); + BlockVector3 max = cityBounds.getMax(); ProtectedCuboidRegion resizedRegion = new ProtectedCuboidRegion(region.getId(), min, max); resizedRegion.copyFrom(region); @@ -1233,6 +1259,7 @@ public class MetropolisPlugin extends JavaPlugin { homeNumber, newHomeRegion.getMinimumPoint(), newHomeRegion.getMaximumPoint()); + resizeCityRegion(); _currentHomes.put(playerId, homeNumber); saveCurrentHomes(); @@ -1383,6 +1410,7 @@ public class MetropolisPlugin extends JavaPlugin { regionManager.addRegion(reservedRegion); _occupiedPlots.add(Plot.get(reservedRegion)); + resizeCityRegion(); saveRegions(); } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBounds.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBounds.java new file mode 100644 index 0000000..f971999 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBounds.java @@ -0,0 +1,59 @@ +/* +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; + +public final class PlotWorldBorderBounds { + private PlotWorldBorderBounds() {} + + public static int maximumSearchRing( + double borderSize, + double borderCenterX, + double borderCenterZ, + int offsetX, + int offsetZ, + int gridSizeX, + int gridSizeZ, + int plotMultiplier) { + if (borderSize <= 0 || gridSizeX <= 0 || gridSizeZ <= 0 || plotMultiplier < 1) { + return 0; + } + + double halfBorder = borderSize / 2; + double farthestX = halfBorder + Math.abs(borderCenterX - offsetX); + double farthestZ = halfBorder + Math.abs(borderCenterZ - offsetZ); + double cellRadius = Math.max(farthestX / gridSizeX, farthestZ / gridSizeZ); + long bound = (long) Math.ceil(cellRadius) + plotMultiplier + 1L; + return (int) Math.min(bound, Integer.MAX_VALUE); + } + + public static boolean contains(Cuboid bounds, double borderSize, double centerX, double centerZ) { + if (borderSize <= 0) { + return false; + } + + double halfBorder = borderSize / 2; + double borderMinX = centerX - halfBorder; + double borderMaxX = centerX + halfBorder; + double borderMinZ = centerZ - halfBorder; + double borderMaxZ = centerZ + halfBorder; + return bounds.minX + 0.5 >= borderMinX + && bounds.maxX + 0.5 <= borderMaxX + && bounds.minZ + 0.5 >= borderMinZ + && bounds.maxZ + 0.5 <= borderMaxZ; + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBoundsTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBoundsTest.java new file mode 100644 index 0000000..3ae72b7 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CityRegionBoundsTest.java @@ -0,0 +1,44 @@ +/* +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 java.util.List; + +import org.junit.jupiter.api.Test; + +class CityRegionBoundsTest { + @Test + void expandsExistingCityToIncludeElevatedHomesAndSpawnWithoutShrinking() { + Cuboid existingCity = new Cuboid(-20, 62, -20, 20, 71, 20); + Cuboid requestedCity = new Cuboid(-40, 62, -40, 40, 71, 40); + Cuboid elevatedHome = new Cuboid(10, 104, 10, 41, 113, 41); + Cuboid spawn = new Cuboid(2, 62, 2, 69, 81, 69); + + Cuboid expanded = + CityRegionBounds.expandToContain(existingCity, requestedCity, List.of(elevatedHome, spawn)); + + assertEquals(-40, expanded.minX); + assertEquals(62, expanded.minY); + assertEquals(-40, expanded.minZ); + assertEquals(69, expanded.maxX); + assertEquals(113, expanded.maxY); + assertEquals(69, expanded.maxZ); + } +} diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBoundsTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBoundsTest.java new file mode 100644 index 0000000..526c828 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlotWorldBorderBoundsTest.java @@ -0,0 +1,45 @@ +/* +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PlotWorldBorderBoundsTest { + @Test + void searchBoundFollowsTheWorldBorderInsteadOfAnArbitraryRingCount() { + int maximumRing = PlotWorldBorderBounds.maximumSearchRing(60_000_000, 0, 0, 0, 0, 36, 36, 1); + + assertTrue(maximumRing > 256); + } + + @Test + void accountsForAnOffsetBorderCenterAndMultiplier() { + int maximumRing = PlotWorldBorderBounds.maximumSearchRing(512, 100, -100, 64, -32, 36, 34, 2); + + assertTrue(maximumRing > 10); + } + + @Test + void requiresTheCompleteGridFootprintInsideTheBorder() { + assertTrue(PlotWorldBorderBounds.contains(new Cuboid(-9, 0, -9, 9, 10, 9), 20, 0, 0)); + assertFalse(PlotWorldBorderBounds.contains(new Cuboid(-11, 0, -9, 9, 10, 9), 20, 0, 0)); + } +}