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(