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); + } +}