From 281735475c86ab0bcca49df617785273c8577d5d Mon Sep 17 00:00:00 2001 From: Jasupa Date: Sat, 5 Sep 2026 14:25:34 +0200 Subject: [PATCH 1/3] feat(rail): add persistent multi-track railway generation and editor Replace the fixed rail preset with configurable rail types stored in rail-types.yml. Split configuration, generation and menus so persistence, geometry and player interaction can be reviewed independently. Generate one to eight aligned tracks with individual gaps, sleepers, optional overhead poles, supports and wires. Validate overlapping paths, the terrain preparation envelope and the complete placement set before queuing blocks. Keep preparation progress and cancellation tied to the active generation. Provide in-game selection, search, pagination, creation, editing and deletion. Version the configuration and make save/delete transactional so failed writes do not change the active types. Add permissions for rail actions and multiple tracks. The track-switch setting is only a stored option for future generation support. Consolidate the previous implementation commits on the current main branch. Retain the existing dependency and Shadow packaging configuration; the unrelated relocation changes are excluded. --- .../generator/components/rail/Rail.java | 20 +- .../components/rail/RailBlockBuilder.java | 268 --------- .../generator/components/rail/RailFlag.java | 4 +- .../components/rail/RailLanePathBuilder.java | 211 ------- .../components/rail/RailPermissionGuard.java | 20 + .../components/rail/RailSettings.java | 3 +- .../generator/components/rail/RailType.java | 32 - .../rail/configuration/RailType.java | 513 ++++++++++++++++ .../rail/configuration/RailTypeManager.java | 482 +++++++++++++++ .../rail/{ => generation}/PositionKey.java | 2 +- .../rail/generation/RailBlockBuilder.java | 487 +++++++++++++++ .../rail/{ => generation}/RailColumnKey.java | 2 +- .../rail/generation/RailConnections.java | 31 + .../rail/generation/RailLanePathBuilder.java | 166 +++++ .../rail/{ => generation}/RailLimits.java | 64 +- .../rail/generation/RailOverheadBuilder.java | 396 ++++++++++++ .../generation/RailPathOverlapValidator.java | 117 ++++ .../RailPreparationProgress.java | 60 +- .../rail/{ => generation}/RailScripts.java | 149 ++++- .../rail/{ => generation}/RailSideBlock.java | 2 +- .../{ => generation}/RailSidePlacement.java | 2 +- .../rail/{ => generation}/RailStep.java | 6 +- .../{ => generation}/RailTerrainResolver.java | 2 +- .../rail/menu/RailBlockPickerMenu.java | 86 +++ .../components/rail/menu/RailBlockRole.java | 130 ++++ .../components/rail/menu/RailMenuText.java | 16 + .../rail/menu/RailOverheadWireMenu.java | 318 ++++++++++ .../rail/menu/RailTrackSpacingMenu.java | 105 ++++ .../components/rail/menu/RailTypeDraft.java | 100 +++ .../rail/menu/RailTypeEditorMenu.java | 382 ++++++++++++ .../components/rail/menu/RailTypeMenu.java | 567 +++++++++++++++++- .../rail/menu/RailTypeMenuItems.java | 228 +++++++ .../modules/generator/menu/GeneratorMenu.java | 259 ++++---- .../modules/generator/model/FlagType.java | 2 +- .../modules/generator/model/Settings.java | 2 +- .../modules/network/model/Permissions.java | 6 + .../resources/modules/generator/config.yml | 12 +- src/main/resources/plugin.yml | 25 + 38 files changed, 4562 insertions(+), 715 deletions(-) delete mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java delete mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java delete mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/PositionKey.java (95%) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailColumnKey.java (89%) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailLimits.java (54%) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailPreparationProgress.java (57%) rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailScripts.java (72%) rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailSideBlock.java (97%) rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailSidePlacement.java (88%) rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailStep.java (51%) rename src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/{ => generation}/RailTerrainResolver.java (99%) create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java index 83809e8c..1227d88e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java @@ -6,9 +6,14 @@ import com.sk89q.worldedit.regions.CuboidRegion; import com.sk89q.worldedit.regions.Polygonal2DRegion; import com.sk89q.worldedit.regions.Region; +import lombok.Getter; +import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.generation.RailScripts; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorType; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import org.bukkit.Sound; import org.bukkit.entity.Player; @@ -20,8 +25,12 @@ public class Rail extends GeneratorComponent { private final Set preparingPlayers = ConcurrentHashMap.newKeySet(); + @Getter + private final RailTypeManager railTypeManager; + public Rail() { super(GeneratorType.RAIL); + railTypeManager = new RailTypeManager(BuildTeamTools.getInstance().getDataFolder()); } @Override @@ -38,7 +47,7 @@ public boolean checkForPlayer(Player player) { "Rail Generator supports cuboid, polygonal and convex WorldEdit selections." ))); player.closeInventory(); - player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0F, 1.0F); + playSound(player, Sound.ENTITY_ITEM_BREAK); return false; } @@ -50,6 +59,9 @@ private boolean isSupportedRailSelection(Region region) { @Override public void generate(Player player) { + if (!RailPermissionGuard.check(player, Permissions.RAIL_GENERATOR_USE)) + return; + if (GeneratorModule.getInstance().isGenerating(player) || !preparingPlayers.add(player.getUniqueId())) { sendAlreadyGeneratingMessage(player); return; @@ -67,6 +79,10 @@ private void sendAlreadyGeneratingMessage(Player player) { player.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( "Rail Generator is already running. Please wait until the current generation is finished." ))); - player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0F, 1.0F); + playSound(player, Sound.ENTITY_ITEM_BREAK); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java deleted file mode 100644 index b2e130e8..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailBlockBuilder.java +++ /dev/null @@ -1,268 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.alpsbte.alpslib.utils.GeneratorUtils; -import com.cryptomorin.xseries.XMaterial; -import com.sk89q.worldedit.util.Direction; -import com.sk89q.worldedit.world.block.BlockState; -import com.sk89q.worldedit.world.block.BlockTypes; -import org.bukkit.util.Vector; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -final class RailBlockBuilder { - - private static final Direction DEFAULT_FACING = Direction.EAST; - private static final XMaterial[] CENTER_MATERIALS = new XMaterial[]{ - XMaterial.DEAD_FIRE_CORAL_BLOCK, - XMaterial.STONE, - XMaterial.COBBLESTONE - }; - - private final List controlPoints; - private final RailTerrainResolver terrainResolver; - private final RailType railType; - private final RailPreparationProgress preparationProgress; - private final int railLaneCount; - private final int railLaneSpacing; - private final long terrainAdjustedPercentage; - private final long buildFinishedPercentage; - - RailBlockBuilder( - List controlPoints, - RailTerrainResolver terrainResolver, - RailType railType, - RailPreparationProgress preparationProgress, - int railLaneCount, - int railLaneSpacing, - long terrainAdjustedPercentage, - long buildFinishedPercentage - ) { - this.controlPoints = controlPoints; - this.terrainResolver = terrainResolver; - this.railType = railType; - this.preparationProgress = preparationProgress; - this.railLaneCount = railLaneCount; - this.railLaneSpacing = railLaneSpacing; - this.terrainAdjustedPercentage = terrainAdjustedPercentage; - this.buildFinishedPercentage = buildFinishedPercentage; - } - - Map build(List path) { - Map railBlocks = new LinkedHashMap<>(); - List> railCenterPaths = new RailLanePathBuilder(controlPoints, terrainResolver, railLaneCount, railLaneSpacing) - .createRailCenterPaths(path); - Set centerPositions = getCenterPositions(railCenterPaths); - Map sideBlocks = new LinkedHashMap<>(); - int totalPathPoints = getTotalPathPointCount(railCenterPaths); - int processedPathPoints = 0; - - for (List railCenterPath : railCenterPaths) { - for (int index = 0; index < railCenterPath.size(); index++) { - Vector center = railCenterPath.get(index); - - for (RailSidePlacement sidePlacement : getSidePlacements(railCenterPath, index)) - addSideBlock(sideBlocks, center, sidePlacement, centerPositions); - - processedPathPoints++; - preparationProgress.update(preparationProgress.scale(processedPathPoints, totalPathPoints, terrainAdjustedPercentage, 86L)); - } - } - - int processedSideBlocks = 0; - - for (RailSideBlock sideBlock : sideBlocks.values()) { - railBlocks.put(sideBlock.key(), createAnvilBlockState(resolveSideBlockFacing(sideBlock, sideBlocks))); - processedSideBlocks++; - preparationProgress.update(preparationProgress.scale(processedSideBlocks, sideBlocks.size(), 86L, 89L)); - } - - int processedCenterPoints = 0; - - for (List railCenterPath : railCenterPaths) { - for (Vector center : railCenterPath) { - railBlocks.put(PositionKey.from(center), createCenterBlockState(center)); - processedCenterPoints++; - preparationProgress.update(preparationProgress.scale(processedCenterPoints, totalPathPoints, 89L, buildFinishedPercentage)); - } - } - - return railBlocks; - } - - private int getTotalPathPointCount(List> railCenterPaths) { - int totalPathPoints = 0; - - for (List railCenterPath : railCenterPaths) - totalPathPoints += railCenterPath.size(); - - return Math.max(1, totalPathPoints); - } - - private List getSidePlacements(List path, int index) { - List placements = new ArrayList<>(); - Vector center = path.get(index); - RailStep previousStep = index > 0 ? getStep(path.get(index - 1), center) : null; - RailStep nextStep = index < path.size() - 1 ? getStep(center, path.get(index + 1)) : null; - - addSidePlacements(placements, getRailStep(path, index, new RailStep(1, 0))); - - if (previousStep != null) - addSidePlacements(placements, previousStep); - - if (nextStep != null) - addSidePlacements(placements, nextStep); - - return placements; - } - - private void addSidePlacements(List placements, RailStep step) { - if (step.dx() != 0 && step.dz() != 0) { - addSidePlacement(placements, new RailStep(step.dx(), 0), GeneratorUtils.getFacing(0, step.dz(), DEFAULT_FACING)); - addSidePlacement(placements, new RailStep(0, step.dz()), GeneratorUtils.getFacing(step.dx(), 0, DEFAULT_FACING)); - return; - } - - if (step.dx() != 0) { - Direction facing = GeneratorUtils.getFacing(step.dx(), 0, DEFAULT_FACING); - addSidePlacement(placements, new RailStep(0, 1), facing); - addSidePlacement(placements, new RailStep(0, -1), facing); - return; - } - - Direction facing = GeneratorUtils.getFacing(0, step.dz(), DEFAULT_FACING); - addSidePlacement(placements, new RailStep(1, 0), facing); - addSidePlacement(placements, new RailStep(-1, 0), facing); - } - - private void addSidePlacement(List placements, RailStep offset, Direction facing) { - for (RailSidePlacement placement : placements) { - if (placement.offset().equals(offset)) return; - } - - placements.add(new RailSidePlacement(offset, facing)); - } - - private void addSideBlock( - Map sideBlocks, - Vector center, - RailSidePlacement sidePlacement, - Set centerPositions - ) { - RailStep sideOffset = sidePlacement.offset(); - - if (sideOffset.dx() == 0 && sideOffset.dz() == 0) - return; - - int x = center.getBlockX() + sideOffset.dx(); - int z = center.getBlockZ() + sideOffset.dz(); - int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); - - PositionKey key = PositionKey.of(x, y, z); - - if (centerPositions.contains(key)) - return; - - sideBlocks - .computeIfAbsent(key, ignored -> new RailSideBlock(key, DEFAULT_FACING)) - .addFacing(sidePlacement.facing()); - } - - private Direction resolveSideBlockFacing(RailSideBlock sideBlock, Map sideBlocks) { - PositionKey key = sideBlock.key(); - boolean east = sideBlocks.containsKey(PositionKey.of(key.x() + 1, key.y(), key.z())); - boolean west = sideBlocks.containsKey(PositionKey.of(key.x() - 1, key.y(), key.z())); - boolean south = sideBlocks.containsKey(PositionKey.of(key.x(), key.y(), key.z() + 1)); - boolean north = sideBlocks.containsKey(PositionKey.of(key.x(), key.y(), key.z() - 1)); - int xConnections = (east ? 1 : 0) + (west ? 1 : 0); - int zConnections = (south ? 1 : 0) + (north ? 1 : 0); - Direction preferredFacing = sideBlock.getPreferredFacing(); - - if (xConnections > zConnections) - return resolveAxisFacing(preferredFacing, Direction.EAST, Direction.WEST, east, west); - - if (zConnections > xConnections) - return resolveAxisFacing(preferredFacing, Direction.SOUTH, Direction.NORTH, south, north); - - return preferredFacing; - } - - private Direction resolveAxisFacing( - Direction preferredFacing, - Direction positiveFacing, - Direction negativeFacing, - boolean hasPositiveNeighbor, - boolean hasNegativeNeighbor - ) { - if (preferredFacing == positiveFacing && hasPositiveNeighbor || preferredFacing == negativeFacing && hasNegativeNeighbor) - return preferredFacing; - - if (hasPositiveNeighbor && !hasNegativeNeighbor) - return positiveFacing; - - if (hasNegativeNeighbor && !hasPositiveNeighbor) - return negativeFacing; - - return preferredFacing == negativeFacing ? negativeFacing : positiveFacing; - } - - private Set getCenterPositions(List> railCenterPaths) { - Set centerPositions = new HashSet<>(); - - for (List railCenterPath : railCenterPaths) - for (Vector center : railCenterPath) - centerPositions.add(PositionKey.from(center)); - - return centerPositions; - } - - private RailStep getRailStep(List path, int index, RailStep fallbackStep) { - RailStep previousStep = index > 0 ? getStep(path.get(index - 1), path.get(index)) : null; - RailStep nextStep = index < path.size() - 1 ? getStep(path.get(index), path.get(index + 1)) : null; - - if (previousStep != null && nextStep != null) { - int dx = Integer.compare(previousStep.dx() + nextStep.dx(), 0); - int dz = Integer.compare(previousStep.dz() + nextStep.dz(), 0); - - if (dx != 0 || dz != 0) return new RailStep(dx, dz); - } - - if (nextStep != null) return nextStep; - - if (previousStep != null) return previousStep; - - return fallbackStep; - } - - private RailStep getStep(Vector from, Vector to) { - int dx = Integer.compare(to.getBlockX() - from.getBlockX(), 0); - int dz = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); - - if (dx == 0 && dz == 0) return null; - - return new RailStep(dx, dz); - } - - private BlockState createCenterBlockState(Vector position) { - return switch (railType) { - case STANDARD -> createStandardCenterBlockState(position); - }; - } - - private BlockState createStandardCenterBlockState(Vector position) { - int index = Math.floorMod( - position.getBlockX() * 31 + position.getBlockY() * 23 + position.getBlockZ() * 17, - CENTER_MATERIALS.length - ); - - return GeneratorUtils.getBlockState(CENTER_MATERIALS[index]); - } - - private BlockState createAnvilBlockState(Direction direction) { - return GeneratorUtils.getBlockStateWithFacing(BlockTypes.ANVIL, direction); - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java index a60a1ccf..e3e0d1ad 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailFlag.java @@ -7,7 +7,9 @@ public enum RailFlag implements Flag { - RAIL_TYPE("t", FlagType.RAIL_TYPE); + RAIL_TYPE("t", FlagType.RAIL_TYPE), + TRACK_COUNT("c", FlagType.INTEGER), + TRACK_SPACING("s", FlagType.INTEGER); @Getter private final String flag; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java deleted file mode 100644 index 62f2ae9d..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLanePathBuilder.java +++ /dev/null @@ -1,211 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.alpsbte.alpslib.utils.GeneratorUtils; -import org.bukkit.util.Vector; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -final class RailLanePathBuilder { - - private final List controlPoints; - private final RailTerrainResolver terrainResolver; - private final int railLaneCount; - private final int railLaneSpacing; - - RailLanePathBuilder(List controlPoints, RailTerrainResolver terrainResolver, int railLaneCount, int railLaneSpacing) { - this.controlPoints = controlPoints; - this.terrainResolver = terrainResolver; - this.railLaneCount = railLaneCount; - this.railLaneSpacing = railLaneSpacing; - } - - List> createRailCenterPaths(List path) { - if (railLaneCount <= 1) - return List.of(path); - - List> railCenterPaths = new ArrayList<>(); - int sideLaneCount = (railLaneCount - 1) / 2; - - for (int laneIndex = sideLaneCount; laneIndex >= 1; laneIndex--) { - List leftLane = createShiftedRailLane(laneIndex * railLaneSpacing, 1); - - if (leftLane.size() >= 2) - railCenterPaths.add(leftLane); - } - - railCenterPaths.add(path); - - for (int laneIndex = 1; laneIndex <= sideLaneCount; laneIndex++) { - List rightLane = createShiftedRailLane(laneIndex * railLaneSpacing, -1); - - if (rightLane.size() >= 2) - railCenterPaths.add(rightLane); - } - - return railCenterPaths; - } - - private List createShiftedRailLane(int distance, int sideSign) { - List> shiftedLines = GeneratorUtils.shiftPointsAll(controlPoints, distance); - List candidatePoints = flattenShiftedLines(shiftedLines); - - if (candidatePoints.isEmpty()) - return Collections.emptyList(); - - List shiftedControlPoints = createShiftedControlPoints(candidatePoints, distance, sideSign); - - if (shiftedControlPoints.size() < 2) - return Collections.emptyList(); - - List shiftedPath = createCenterPath(shiftedControlPoints); - - if (shiftedPath.size() < 2) - return Collections.emptyList(); - - terrainResolver.adjustPathToTerrain(shiftedPath); - return shiftedPath; - } - - private List flattenShiftedLines(List> shiftedLines) { - if (shiftedLines == null || shiftedLines.isEmpty()) - return Collections.emptyList(); - - List candidatePoints = new ArrayList<>(); - - for (List shiftedLine : shiftedLines) { - if (shiftedLine == null || shiftedLine.isEmpty()) - continue; - - for (Vector point : shiftedLine) { - if (point != null) - candidatePoints.add(point); - } - } - - return candidatePoints; - } - - private List createShiftedControlPoints(List candidatePoints, int distance, int sideSign) { - List shiftedControlPoints = new ArrayList<>(); - - for (int index = 0; index < controlPoints.size(); index++) { - Vector basePoint = controlPoints.get(index); - Vector direction = getControlPointDirection(index); - - if (direction.lengthSquared() == 0) - continue; - - direction.normalize(); - - Vector normal = new Vector(-direction.getZ(), 0, direction.getX()); - - if (sideSign < 0) - normal.multiply(-1); - - Vector shiftedPoint = getBestShiftedPoint(basePoint, normal, candidatePoints, distance); - addIfDifferentFromPrevious(shiftedControlPoints, shiftedPoint); - } - - return shiftedControlPoints; - } - - private Vector getControlPointDirection(int index) { - if (controlPoints.size() < 2) - return new Vector(0, 0, 0); - - if (index == 0) - return getHorizontalDirection(controlPoints.get(0), controlPoints.get(1)); - - if (index == controlPoints.size() - 1) - return getHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)); - - Vector previousDirection = getHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)); - Vector nextDirection = getHorizontalDirection(controlPoints.get(index), controlPoints.get(index + 1)); - Vector combinedDirection = previousDirection.add(nextDirection); - - if (combinedDirection.lengthSquared() != 0) - return combinedDirection; - - if (nextDirection.lengthSquared() != 0) - return nextDirection; - - return previousDirection; - } - - private Vector getHorizontalDirection(Vector from, Vector to) { - return new Vector( - to.getBlockX() - from.getBlockX(), - 0, - to.getBlockZ() - from.getBlockZ() - ); - } - - private Vector getBestShiftedPoint(Vector basePoint, Vector normal, List candidatePoints, int distance) { - Vector idealPoint = getIdealShiftedPoint(basePoint, normal, distance); - Vector bestPoint = null; - double bestDistanceSquared = Double.MAX_VALUE; - - for (Vector candidatePoint : candidatePoints) { - double signedOffset = getSignedOffset(basePoint, candidatePoint, normal); - - if (signedOffset < 0.5D) - continue; - - double distanceSquared = getHorizontalDistanceSquared(candidatePoint, idealPoint); - - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestPoint = candidatePoint; - } - } - - double maxCandidateDistanceSquared = Math.max(16D, distance * distance * 2.25D); - - if (bestPoint == null || bestDistanceSquared > maxCandidateDistanceSquared) - return idealPoint; - - return new Vector(bestPoint.getBlockX(), basePoint.getBlockY(), bestPoint.getBlockZ()); - } - - private Vector getIdealShiftedPoint(Vector basePoint, Vector normal, int distance) { - return new Vector( - basePoint.getBlockX() + (int) Math.round(normal.getX() * distance), - basePoint.getBlockY(), - basePoint.getBlockZ() + (int) Math.round(normal.getZ() * distance) - ); - } - - private double getSignedOffset(Vector basePoint, Vector candidatePoint, Vector normal) { - double offsetX = candidatePoint.getX() - basePoint.getX(); - double offsetZ = candidatePoint.getZ() - basePoint.getZ(); - - return offsetX * normal.getX() + offsetZ * normal.getZ(); - } - - private void addIfDifferentFromPrevious(List points, Vector point) { - if (points.isEmpty()) { - points.add(point); - return; - } - - Vector previousPoint = points.get(points.size() - 1); - - if (previousPoint.getBlockX() == point.getBlockX() && previousPoint.getBlockZ() == point.getBlockZ()) - return; - - points.add(point); - } - - private double getHorizontalDistanceSquared(Vector first, Vector second) { - double dx = first.getX() - second.getX(); - double dz = first.getZ() - second.getZ(); - - return dx * dx + dz * dz; - } - - private List createCenterPath(List points) { - return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java new file mode 100644 index 00000000..1f3411ae --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java @@ -0,0 +1,20 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail; + +import com.alpsbte.alpslib.utils.ChatHelper; +import lombok.experimental.UtilityClass; +import org.bukkit.entity.Player; + +@UtilityClass +public class RailPermissionGuard { + + public static boolean check(Player player, String permission) { + if (player.hasPermission(permission)) + return true; + + player.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "You don't have permission to do this. Required permission: %s", + permission + ))); + return false; + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java index ae244c3d..665681e6 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSettings.java @@ -1,5 +1,6 @@ package net.buildtheearth.buildteamtools.modules.generator.components.rail; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; import org.bukkit.entity.Player; @@ -11,6 +12,6 @@ public RailSettings(Player player) { @Override public void setDefaultValues() { - setValue(RailFlag.RAIL_TYPE, RailType.STANDARD); + setValue(RailFlag.RAIL_TYPE, RailType.getDefault()); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java deleted file mode 100644 index 6c641532..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailType.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.cryptomorin.xseries.XMaterial; -import lombok.Getter; -import org.jspecify.annotations.Nullable; - -public enum RailType { - - STANDARD("standard", "Standard", XMaterial.RAIL); - - @Getter - private final String identifier; - @Getter - private final String displayName; - @Getter - private final XMaterial icon; - - RailType(String identifier, String displayName, XMaterial icon) { - this.identifier = identifier; - this.displayName = displayName; - this.icon = icon; - } - - public static @Nullable RailType byString(String value) { - for (RailType railType : RailType.values()) { - if (railType.getIdentifier().equalsIgnoreCase(value) || railType.name().equalsIgnoreCase(value)) - return railType; - } - - return null; - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java new file mode 100644 index 00000000..92ee3d93 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java @@ -0,0 +1,513 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; +import org.bukkit.Material; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +@Getter +public final class RailType { + + public static final String STANDARD_IDENTIFIER = "standard"; + + public static final int MIN_TRACK_COUNT = 1; + public static final int MAX_TRACK_COUNT = 8; + public static final int MIN_TRACK_SPACING = 3; + public static final int MAX_TRACK_SPACING = 32; + // A sleeper spacing of 0 disables sleepers entirely. + public static final int MIN_SLEEPER_SPACING = 0; + public static final int MAX_SLEEPER_SPACING = 16; + public static final int MIN_OVERHEAD_POLE_SPACING = 1; + public static final int MAX_OVERHEAD_POLE_SPACING = 512; + public static final int MIN_OVERHEAD_POLE_OFFSET = 1; + public static final int MAX_OVERHEAD_POLE_OFFSET = 16; + public static final int MIN_OVERHEAD_POLE_HEIGHT = 3; + public static final int MAX_OVERHEAD_POLE_HEIGHT = 16; + public static final int MAX_IDENTIFIER_LENGTH = 32; + public static final int MAX_DISPLAY_NAME_LENGTH = 48; + + public static final int DEFAULT_TRACK_COUNT = 2; + public static final int DEFAULT_TRACK_SPACING = 5; + public static final int DEFAULT_OVERHEAD_POLE_SPACING = 16; + public static final int DEFAULT_OVERHEAD_POLE_OFFSET = 3; + public static final int DEFAULT_OVERHEAD_POLE_HEIGHT = 6; + + private static final String IDENTIFIER_PATTERN = "[a-z0-9_-]+"; + + private final String identifier; + private final String displayName; + private final XMaterial icon; + private final XMaterial railBlock; + private final List blocksBelow; + private final @Nullable XMaterial sleeperBlock; + private final int sleeperSpacing; + private final int trackCount; + private final int trackSpacing; + private final List trackSpacings; + private final boolean overheadPolesEnabled; + private final @Nullable XMaterial overheadPoleBlock; + private final @Nullable XMaterial overheadSupportBlock; + private final int overheadPoleSpacing; + private final int overheadPoleOffset; + private final int overheadPoleHeight; + private final boolean overheadWiresEnabled; + private final @Nullable XMaterial overheadWireBlock; + private final boolean trackSwitchesEnabled; + private final boolean builtIn; + + RailType(Configuration configuration, boolean builtIn) { + List configuredBlocksBelow = configuration.blocksBelow(); + + this.identifier = configuration.identifier(); + this.displayName = configuration.displayName(); + this.icon = configuration.icon(); + this.railBlock = configuration.railBlock(); + this.blocksBelow = configuredBlocksBelow == null ? List.of() : List.copyOf(configuredBlocksBelow); + this.sleeperBlock = configuration.sleeperBlock(); + this.sleeperSpacing = configuration.sleeperSpacing(); + this.trackCount = configuration.trackCount(); + this.trackSpacing = configuration.trackSpacing(); + this.trackSpacings = normalizeTrackSpacings( + configuration.trackSpacings(), + configuration.trackCount(), + configuration.trackSpacing() + ); + this.overheadPolesEnabled = configuration.overheadPolesEnabled(); + this.overheadPoleBlock = configuration.overheadPoleBlock(); + this.overheadSupportBlock = configuration.overheadSupportBlock(); + this.overheadPoleSpacing = configuration.overheadPoleSpacing(); + this.overheadPoleOffset = configuration.overheadPoleOffset(); + this.overheadPoleHeight = configuration.overheadPoleHeight(); + this.overheadWiresEnabled = configuration.overheadWiresEnabled(); + this.overheadWireBlock = configuration.overheadWireBlock(); + this.trackSwitchesEnabled = configuration.trackSwitchesEnabled(); + this.builtIn = builtIn; + } + + public boolean hasSleepers() { + return sleeperSpacing > 0 && sleeperBlock != null; + } + + public boolean hasOverheadPoles() { + return overheadPolesEnabled && overheadPoleBlock != null; + } + + public boolean hasOverheadWires() { + return overheadWiresEnabled && overheadWireBlock != null; + } + + /** + * Creates a custom rail type with the rail block as menu icon. + * Call {@link RailTypeManager#saveRailType(RailType)} to validate and persist it. + */ + public static RailType createCustom(Configuration configuration) { + return new RailType(configuration, false); + } + + static RailType createStandard() { + return new RailType(new Configuration() + .identifier(STANDARD_IDENTIFIER) + .displayName("Standard") + .icon(XMaterial.RAIL) + .railBlock(XMaterial.ANVIL) + .blocksBelow(List.of(XMaterial.DEAD_FIRE_CORAL_BLOCK, XMaterial.STONE, XMaterial.COBBLESTONE)) + .sleeperBlock(XMaterial.SPRUCE_PLANKS) + .sleeperSpacing(0) + .trackCount(DEFAULT_TRACK_COUNT) + .trackSpacing(DEFAULT_TRACK_SPACING) + .trackSpacings(List.of(DEFAULT_TRACK_SPACING)) + .overheadPolesEnabled(true) + .overheadPoleBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadSupportBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadPoleSpacing(DEFAULT_OVERHEAD_POLE_SPACING) + .overheadPoleOffset(DEFAULT_OVERHEAD_POLE_OFFSET) + .overheadPoleHeight(DEFAULT_OVERHEAD_POLE_HEIGHT) + .overheadWiresEnabled(true) + .overheadWireBlock(XMaterial.IRON_BARS) + .trackSwitchesEnabled(false), true); + } + + /** + * Validates all configurable rail type values. + * + * @return A human-readable error message, or null if all values are valid. + */ + public static @Nullable String validate(Configuration configuration) { + String error = validateIdentifier(configuration.identifier()); + + if (error != null) + return error; + + error = validateDisplayName(configuration.displayName()); + + if (error != null) + return error; + + error = validateBlocks(configuration); + + if (error != null) + return error; + + error = validateSleeper(configuration); + + if (error != null) + return error; + + error = validateTracks(configuration); + + if (error != null) + return error; + + return validateOverheadWires(configuration); + } + + private static @Nullable String validateIdentifier(@Nullable String identifier) { + if (!hasInvalidIdentifier(identifier)) + return null; + + return "Rail type name must be %s characters or less and may only contain letters, numbers, '-' and '_'." + .formatted(MAX_IDENTIFIER_LENGTH); + } + + private static @Nullable String validateDisplayName(@Nullable String displayName) { + if (displayName != null && !displayName.isBlank() && displayName.length() <= MAX_DISPLAY_NAME_LENGTH) + return null; + + return "Rail type display name must not be empty and must be %s characters or less." + .formatted(MAX_DISPLAY_NAME_LENGTH); + } + + private static @Nullable String validateBlocks(Configuration configuration) { + if (!isPlaceableBlock(configuration.railBlock())) + return "Rail block must be a valid placeable Minecraft block."; + + List blocksBelow = configuration.blocksBelow(); + + if (blocksBelow == null || blocksBelow.isEmpty()) + return "Rail type needs at least one block below the rails."; + + for (XMaterial blockBelow : blocksBelow) + if (!isPlaceableBlock(blockBelow)) + return "Blocks below the rails must be valid placeable Minecraft blocks."; + + return null; + } + + private static @Nullable String validateSleeper(Configuration configuration) { + int sleeperSpacing = configuration.sleeperSpacing(); + + if (sleeperSpacing < MIN_SLEEPER_SPACING || sleeperSpacing > MAX_SLEEPER_SPACING) + return "Sleeper spacing must be between %s and %s. Use 0 to disable sleepers." + .formatted(MIN_SLEEPER_SPACING, MAX_SLEEPER_SPACING); + + if (sleeperSpacing > 0 && !isPlaceableBlock(configuration.sleeperBlock())) + return "Sleeper block must be a valid placeable Minecraft block."; + + return null; + } + + private static @Nullable String validateTracks(Configuration configuration) { + int trackCount = configuration.trackCount(); + + if (trackCount < MIN_TRACK_COUNT || trackCount > MAX_TRACK_COUNT) + return "Track count must be between %s and %s.".formatted(MIN_TRACK_COUNT, MAX_TRACK_COUNT); + + int trackSpacing = configuration.trackSpacing(); + + if (trackSpacing < MIN_TRACK_SPACING || trackSpacing > MAX_TRACK_SPACING) + return "Track spacing must be between %s and %s.".formatted(MIN_TRACK_SPACING, MAX_TRACK_SPACING); + + List trackSpacings = configuration.trackSpacings(); + + if (trackSpacings == null) + return null; + + if (trackSpacings.size() != Math.max(0, trackCount - 1)) + return "Rail type needs exactly one spacing value between every pair of tracks."; + + for (Integer spacing : trackSpacings) + if (spacing == null || spacing < MIN_TRACK_SPACING || spacing > MAX_TRACK_SPACING) + return "Every track spacing must be between %s and %s." + .formatted(MIN_TRACK_SPACING, MAX_TRACK_SPACING); + + return null; + } + + private static List normalizeTrackSpacings(@Nullable List configured, int trackCount, int fallback) { + int spacingCount = Math.max(0, trackCount - 1); + + if (configured != null && configured.size() == spacingCount) + return List.copyOf(configured); + + return java.util.Collections.nCopies(spacingCount, fallback); + } + + private static @Nullable String validateOverheadWires(Configuration configuration) { + String poleError = validateOverheadPoles(configuration); + + if (poleError != null) + return poleError; + + if (configuration.overheadWiresEnabled() && !isPlaceableBlock(configuration.overheadWireBlock())) + return "Overhead wire block must be a valid placeable Minecraft block."; + + return null; + } + + private static @Nullable String validateOverheadPoles(Configuration configuration) { + if (!configuration.overheadPolesEnabled()) + return null; + + if (!isPlaceableBlock(configuration.overheadPoleBlock())) + return "Overhead pole block must be a valid placeable Minecraft block."; + + if (!isPlaceableBlock(configuration.overheadSupportBlock())) + return "Overhead support block must be a valid placeable Minecraft block."; + + return validateOverheadPoleMeasurements(configuration); + } + + private static @Nullable String validateOverheadPoleMeasurements(Configuration configuration) { + if (configuration.overheadPoleSpacing() < MIN_OVERHEAD_POLE_SPACING + || configuration.overheadPoleSpacing() > MAX_OVERHEAD_POLE_SPACING) + return "Overhead pole spacing must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_SPACING, MAX_OVERHEAD_POLE_SPACING); + + if (configuration.overheadPoleOffset() < MIN_OVERHEAD_POLE_OFFSET + || configuration.overheadPoleOffset() > MAX_OVERHEAD_POLE_OFFSET) + return "Overhead pole offset must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_OFFSET, MAX_OVERHEAD_POLE_OFFSET); + + if (configuration.overheadPoleHeight() < MIN_OVERHEAD_POLE_HEIGHT + || configuration.overheadPoleHeight() > MAX_OVERHEAD_POLE_HEIGHT) + return "Overhead pole height must be between %s and %s." + .formatted(MIN_OVERHEAD_POLE_HEIGHT, MAX_OVERHEAD_POLE_HEIGHT); + + return null; + } + + private static boolean hasInvalidIdentifier(@Nullable String identifier) { + return identifier == null + || identifier.isBlank() + || identifier.length() > MAX_IDENTIFIER_LENGTH + || !identifier.toLowerCase().matches(IDENTIFIER_PATTERN); + } + + private static boolean isPlaceableBlock(@Nullable XMaterial material) { + if (material == null) + return false; + + Material bukkitMaterial = material.get(); + return bukkitMaterial != null && bukkitMaterial.isBlock(); + } + + public static @Nullable RailType byString(String value) { + Rail rail = GeneratorModule.getInstance().getRail(); + + return rail == null ? null : rail.getRailTypeManager().byString(value); + } + + public static RailType getDefault() { + Rail rail = GeneratorModule.getInstance().getRail(); + + return rail == null ? createStandard() : rail.getRailTypeManager().getDefault(); + } + + public static final class Configuration { + + private @Nullable String identifier; + private @Nullable String displayName; + private @Nullable XMaterial icon; + private @Nullable XMaterial railBlock; + private @Nullable List blocksBelow; + private @Nullable XMaterial sleeperBlock; + private int sleeperSpacing; + private int trackCount; + private int trackSpacing; + private @Nullable List trackSpacings; + private boolean overheadPolesEnabled; + private @Nullable XMaterial overheadPoleBlock; + private @Nullable XMaterial overheadSupportBlock; + private int overheadPoleSpacing = DEFAULT_OVERHEAD_POLE_SPACING; + private int overheadPoleOffset = DEFAULT_OVERHEAD_POLE_OFFSET; + private int overheadPoleHeight = DEFAULT_OVERHEAD_POLE_HEIGHT; + private boolean overheadWiresEnabled; + private @Nullable XMaterial overheadWireBlock; + private boolean trackSwitchesEnabled; + + public @Nullable String identifier() { + return identifier; + } + + public Configuration identifier(@Nullable String identifier) { + this.identifier = identifier; + return this; + } + + public @Nullable String displayName() { + return displayName; + } + + public Configuration displayName(@Nullable String displayName) { + this.displayName = displayName; + return this; + } + + public @Nullable XMaterial icon() { + return icon; + } + + public Configuration icon(@Nullable XMaterial icon) { + this.icon = icon; + return this; + } + + public @Nullable XMaterial railBlock() { + return railBlock; + } + + public Configuration railBlock(@Nullable XMaterial railBlock) { + this.railBlock = railBlock; + return this; + } + + public @Nullable List blocksBelow() { + return blocksBelow; + } + + public Configuration blocksBelow(@Nullable List blocksBelow) { + this.blocksBelow = blocksBelow; + return this; + } + + public @Nullable XMaterial sleeperBlock() { + return sleeperBlock; + } + + public Configuration sleeperBlock(@Nullable XMaterial sleeperBlock) { + this.sleeperBlock = sleeperBlock; + return this; + } + + public int sleeperSpacing() { + return sleeperSpacing; + } + + public Configuration sleeperSpacing(int sleeperSpacing) { + this.sleeperSpacing = sleeperSpacing; + return this; + } + + public int trackCount() { + return trackCount; + } + + public Configuration trackCount(int trackCount) { + this.trackCount = trackCount; + return this; + } + + public int trackSpacing() { + return trackSpacing; + } + + public Configuration trackSpacing(int trackSpacing) { + this.trackSpacing = trackSpacing; + return this; + } + + public @Nullable List trackSpacings() { + return trackSpacings; + } + + public Configuration trackSpacings(@Nullable List trackSpacings) { + this.trackSpacings = trackSpacings; + return this; + } + + public boolean overheadPolesEnabled() { + return overheadPolesEnabled; + } + + public Configuration overheadPolesEnabled(boolean overheadPolesEnabled) { + this.overheadPolesEnabled = overheadPolesEnabled; + return this; + } + + public @Nullable XMaterial overheadPoleBlock() { + return overheadPoleBlock; + } + + public Configuration overheadPoleBlock(@Nullable XMaterial overheadPoleBlock) { + this.overheadPoleBlock = overheadPoleBlock; + return this; + } + + public @Nullable XMaterial overheadSupportBlock() { + return overheadSupportBlock; + } + + public Configuration overheadSupportBlock(@Nullable XMaterial overheadSupportBlock) { + this.overheadSupportBlock = overheadSupportBlock; + return this; + } + + public int overheadPoleSpacing() { + return overheadPoleSpacing; + } + + public Configuration overheadPoleSpacing(int overheadPoleSpacing) { + this.overheadPoleSpacing = overheadPoleSpacing; + return this; + } + + public int overheadPoleOffset() { + return overheadPoleOffset; + } + + public Configuration overheadPoleOffset(int overheadPoleOffset) { + this.overheadPoleOffset = overheadPoleOffset; + return this; + } + + public int overheadPoleHeight() { + return overheadPoleHeight; + } + + public Configuration overheadPoleHeight(int overheadPoleHeight) { + this.overheadPoleHeight = overheadPoleHeight; + return this; + } + + public boolean overheadWiresEnabled() { + return overheadWiresEnabled; + } + + public Configuration overheadWiresEnabled(boolean overheadWiresEnabled) { + this.overheadWiresEnabled = overheadWiresEnabled; + return this; + } + + public @Nullable XMaterial overheadWireBlock() { + return overheadWireBlock; + } + + public Configuration overheadWireBlock(@Nullable XMaterial overheadWireBlock) { + this.overheadWireBlock = overheadWireBlock; + return this; + } + + public boolean trackSwitchesEnabled() { + return trackSwitchesEnabled; + } + + public Configuration trackSwitchesEnabled(boolean trackSwitchesEnabled) { + this.trackSwitchesEnabled = trackSwitchesEnabled; + return this; + } + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java new file mode 100644 index 00000000..1f957513 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java @@ -0,0 +1,482 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.cryptomorin.xseries.XMaterial; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jspecify.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Holds all available rail types and persists player-created rail types to disk. + * The built-in standard rail type is always available and cannot be overwritten or deleted. + */ +public class RailTypeManager { + + private static final String FILE_PATH = "modules/generator/rail-types.yml"; + private static final String SCHEMA_VERSION_KEY = "schema-version"; + static final int CURRENT_SCHEMA_VERSION = 4; + private static final String TYPES_SECTION = "rail-types"; + + private static final String KEY_DISPLAY_NAME = "display-name"; + private static final String KEY_ICON = "icon"; + private static final String KEY_RAIL_BLOCK = "rail-block"; + private static final String KEY_BLOCKS_BELOW = "blocks-below"; + private static final String KEY_SLEEPER_BLOCK = "sleeper-block"; + private static final String KEY_SLEEPER_SPACING = "sleeper-spacing"; + private static final String KEY_TRACK_COUNT = "track-count"; + private static final String KEY_TRACK_SPACING = "track-spacing"; + private static final String KEY_TRACK_SPACINGS = "track-spacings"; + private static final String KEY_OVERHEAD_POLES_ENABLED = "overhead.poles.enabled"; + private static final String KEY_OVERHEAD_POLE_BLOCK = "overhead.poles.block"; + private static final String KEY_OVERHEAD_SUPPORT_BLOCK = "overhead.support.block"; + private static final String KEY_OVERHEAD_POLE_SPACING = "overhead.poles.spacing"; + private static final String KEY_OVERHEAD_POLE_OFFSET = "overhead.poles.offset"; + private static final String KEY_OVERHEAD_POLE_HEIGHT = "overhead.poles.height"; + private static final String KEY_OVERHEAD_WIRES_ENABLED = "overhead.wires.enabled"; + private static final String KEY_OVERHEAD_WIRE_BLOCK = "overhead.wires.block"; + private static final String KEY_TRACK_SWITCHES_ENABLED = "track-switches.enabled"; + + private final Map railTypes = new LinkedHashMap<>(); + private final File file; + + public RailTypeManager(File dataFolder) { + this.file = new File(dataFolder, FILE_PATH); + reload(); + } + + public void reload() { + railTypes.clear(); + + RailType standard = RailType.createStandard(); + railTypes.put(standard.getIdentifier(), standard); + + if (!file.exists()) + return; + + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + migrateStorage(config); + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) + loadRailType(typesSection.getConfigurationSection(identifier), identifier); + } + + public Collection getRailTypes() { + return Collections.unmodifiableCollection(railTypes.values()); + } + + public RailType getDefault() { + return railTypes.get(RailType.STANDARD_IDENTIFIER); + } + + /** + * @return The first free "custom-N" identifier for a new player-created rail type. + */ + public String getNextCustomIdentifier() { + int index = 1; + + while (railTypes.containsKey("custom-" + index)) + index++; + + return "custom-" + index; + } + + public @Nullable RailType byString(@Nullable String value) { + if (value == null) + return null; + + RailType byIdentifier = railTypes.get(value.toLowerCase(Locale.ROOT)); + + if (byIdentifier != null) + return byIdentifier; + + for (RailType railType : railTypes.values()) + if (railType.getDisplayName().equalsIgnoreCase(value)) + return railType; + + return null; + } + + /** + * Validates and stores a custom rail type and saves it to disk. + * + * @return A human-readable error message, or null if the rail type was saved successfully. + */ + public @Nullable String saveRailType(RailType railType) { + String error = RailType.validate(toConfiguration(railType)); + + if (error != null) + return error; + + RailType existingRailType = railTypes.get(railType.getIdentifier()); + + if (existingRailType != null && existingRailType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be overwritten.".formatted(railType.getIdentifier()); + + railTypes.put(railType.getIdentifier(), railType); + String saveError = save(); + + if (saveError == null) + return null; + + if (existingRailType == null) + railTypes.remove(railType.getIdentifier()); + else + railTypes.put(existingRailType.getIdentifier(), existingRailType); + + return saveError; + } + + /** + * Deletes a custom rail type and saves the change to disk. + * + * @return A human-readable error message, or null if the rail type was deleted successfully. + */ + public @Nullable String deleteRailType(String identifier) { + RailType railType = railTypes.get(identifier.toLowerCase(Locale.ROOT)); + + if (railType == null) + return "The rail type '%s' does not exist.".formatted(identifier); + + if (railType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be deleted.".formatted(identifier); + + railTypes.remove(railType.getIdentifier()); + String saveError = save(); + + if (saveError == null) + return null; + + railTypes.put(railType.getIdentifier(), railType); + return saveError; + } + + /** + * Deletes multiple custom types atomically from the in-memory collection and + * writes the storage file once. Built-in or missing identifiers abort the operation. + */ + public @Nullable String deleteRailTypes(Collection identifiers) { + List typesToDelete = new ArrayList<>(); + + for (String identifier : identifiers) { + RailType railType = railTypes.get(identifier.toLowerCase(Locale.ROOT)); + + if (railType == null) + return "The rail type '%s' does not exist.".formatted(identifier); + + if (railType.isBuiltIn()) + return "The rail type '%s' is built-in and cannot be deleted.".formatted(identifier); + + typesToDelete.add(railType); + } + + for (RailType railType : typesToDelete) + railTypes.remove(railType.getIdentifier()); + + String error = save(); + + if (error == null) + return null; + + for (RailType railType : typesToDelete) + railTypes.put(railType.getIdentifier(), railType); + + return error; + } + + private void loadRailType(@Nullable ConfigurationSection section, String identifier) { + if (section == null) + return; + + String normalizedIdentifier = identifier.toLowerCase(Locale.ROOT); + String displayName = section.getString(KEY_DISPLAY_NAME, identifier); + XMaterial railBlock = parseMaterial(section.getString(KEY_RAIL_BLOCK)); + List blocksBelow = parseMaterials(section.getStringList(KEY_BLOCKS_BELOW)); + XMaterial sleeperBlock = parseMaterial(section.getString(KEY_SLEEPER_BLOCK)); + int sleeperSpacing = section.getInt(KEY_SLEEPER_SPACING, RailType.MIN_SLEEPER_SPACING); + int trackCount = section.getInt(KEY_TRACK_COUNT, RailType.DEFAULT_TRACK_COUNT); + int trackSpacing = section.getInt(KEY_TRACK_SPACING, RailType.DEFAULT_TRACK_SPACING); + List trackSpacings = section.contains(KEY_TRACK_SPACINGS) + ? section.getIntegerList(KEY_TRACK_SPACINGS) + : Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + boolean overheadPolesEnabled = section.getBoolean(KEY_OVERHEAD_POLES_ENABLED, false); + XMaterial overheadPoleBlock = parseMaterial(section.getString(KEY_OVERHEAD_POLE_BLOCK)); + XMaterial overheadSupportBlock = parseMaterial(section.getString(KEY_OVERHEAD_SUPPORT_BLOCK)); + int overheadPoleSpacing = section.getInt( + KEY_OVERHEAD_POLE_SPACING, + RailType.DEFAULT_OVERHEAD_POLE_SPACING + ); + int overheadPoleOffset = section.getInt( + KEY_OVERHEAD_POLE_OFFSET, + RailType.DEFAULT_OVERHEAD_POLE_OFFSET + ); + int overheadPoleHeight = section.getInt( + KEY_OVERHEAD_POLE_HEIGHT, + RailType.DEFAULT_OVERHEAD_POLE_HEIGHT + ); + boolean overheadWiresEnabled = section.getBoolean(KEY_OVERHEAD_WIRES_ENABLED, false); + XMaterial overheadWireBlock = parseMaterial(section.getString(KEY_OVERHEAD_WIRE_BLOCK)); + boolean trackSwitchesEnabled = section.getBoolean(KEY_TRACK_SWITCHES_ENABLED, false); + + String error = RailType.validate(new RailType.Configuration() + .identifier(normalizedIdentifier) + .displayName(displayName) + .icon(railBlock) + .railBlock(railBlock) + .blocksBelow(blocksBelow) + .sleeperBlock(sleeperBlock) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing) + .trackSpacings(trackSpacings) + .overheadPolesEnabled(overheadPolesEnabled) + .overheadPoleBlock(overheadPoleBlock) + .overheadSupportBlock(overheadSupportBlock) + .overheadPoleSpacing(overheadPoleSpacing) + .overheadPoleOffset(overheadPoleOffset) + .overheadPoleHeight(overheadPoleHeight) + .overheadWiresEnabled(overheadWiresEnabled) + .overheadWireBlock(overheadWireBlock) + .trackSwitchesEnabled(trackSwitchesEnabled)); + + if (error != null) { + ChatHelper.logError("Skipping invalid rail type '%s' in %s: %s", identifier, FILE_PATH, error); + return; + } + + if (railTypes.containsKey(normalizedIdentifier)) { + ChatHelper.logError("Skipping duplicate rail type '%s' in %s.", identifier, FILE_PATH); + return; + } + + XMaterial icon = parseMaterial(section.getString(KEY_ICON)); + + railTypes.put(normalizedIdentifier, new RailType(new RailType.Configuration() + .identifier(normalizedIdentifier) + .displayName(displayName) + .icon(icon == null ? railBlock : icon) + .railBlock(railBlock) + .blocksBelow(blocksBelow) + .sleeperBlock(sleeperBlock) + .sleeperSpacing(sleeperSpacing) + .trackCount(trackCount) + .trackSpacing(trackSpacing) + .trackSpacings(trackSpacings) + .overheadPolesEnabled(overheadPolesEnabled) + .overheadPoleBlock(overheadPoleBlock) + .overheadSupportBlock(overheadSupportBlock) + .overheadPoleSpacing(overheadPoleSpacing) + .overheadPoleOffset(overheadPoleOffset) + .overheadPoleHeight(overheadPoleHeight) + .overheadWiresEnabled(overheadWiresEnabled) + .overheadWireBlock(overheadWireBlock) + .trackSwitchesEnabled(trackSwitchesEnabled), false)); + } + + private @Nullable String save() { + YamlConfiguration config = new YamlConfiguration(); + config.set(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION); + + for (RailType railType : railTypes.values()) { + if (railType.isBuiltIn()) + continue; + + String path = TYPES_SECTION + "." + railType.getIdentifier(); + + config.set(path + "." + KEY_DISPLAY_NAME, railType.getDisplayName()); + config.set(path + "." + KEY_ICON, railType.getIcon().name()); + config.set(path + "." + KEY_RAIL_BLOCK, railType.getRailBlock().name()); + config.set(path + "." + KEY_BLOCKS_BELOW, railType.getBlocksBelow().stream().map(XMaterial::name).toList()); + + XMaterial sleeperBlock = railType.getSleeperBlock(); + + if (sleeperBlock != null) + config.set(path + "." + KEY_SLEEPER_BLOCK, sleeperBlock.name()); + + config.set(path + "." + KEY_SLEEPER_SPACING, railType.getSleeperSpacing()); + config.set(path + "." + KEY_TRACK_COUNT, railType.getTrackCount()); + config.set(path + "." + KEY_TRACK_SPACING, railType.getTrackSpacing()); + config.set(path + "." + KEY_TRACK_SPACINGS, railType.getTrackSpacings()); + config.set(path + "." + KEY_OVERHEAD_POLES_ENABLED, railType.isOverheadPolesEnabled()); + config.set(path + "." + KEY_OVERHEAD_POLE_SPACING, railType.getOverheadPoleSpacing()); + config.set(path + "." + KEY_OVERHEAD_POLE_OFFSET, railType.getOverheadPoleOffset()); + config.set(path + "." + KEY_OVERHEAD_POLE_HEIGHT, railType.getOverheadPoleHeight()); + config.set(path + "." + KEY_OVERHEAD_WIRES_ENABLED, railType.isOverheadWiresEnabled()); + config.set(path + "." + KEY_TRACK_SWITCHES_ENABLED, railType.isTrackSwitchesEnabled()); + + XMaterial overheadPoleBlock = railType.getOverheadPoleBlock(); + + if (overheadPoleBlock != null) + config.set(path + "." + KEY_OVERHEAD_POLE_BLOCK, overheadPoleBlock.name()); + + XMaterial overheadSupportBlock = railType.getOverheadSupportBlock(); + + if (overheadSupportBlock != null) + config.set(path + "." + KEY_OVERHEAD_SUPPORT_BLOCK, overheadSupportBlock.name()); + + XMaterial overheadWireBlock = railType.getOverheadWireBlock(); + + if (overheadWireBlock != null) + config.set(path + "." + KEY_OVERHEAD_WIRE_BLOCK, overheadWireBlock.name()); + } + + try { + if (file.getParentFile() != null && !file.getParentFile().exists() && !file.getParentFile().mkdirs()) + throw new IOException("Could not create directory " + file.getParentFile()); + + config.save(file); + return null; + } catch (IOException exception) { + ChatHelper.logError("Could not save rail types to %s.", exception, FILE_PATH); + return "Rail types could not be saved. Please check the server console."; + } + } + + private @Nullable XMaterial parseMaterial(@Nullable String value) { + if (value == null || value.isBlank()) + return null; + + return XMaterial.matchXMaterial(value).orElse(null); + } + + private List parseMaterials(List values) { + List materials = new ArrayList<>(); + + for (String value : values) { + XMaterial material = parseMaterial(value); + + // Keep invalid entries as null so validation reports them instead of silently dropping them. + materials.add(material); + } + + return materials; + } + + private void migrateStorage(YamlConfiguration config) { + int schemaVersion = config.getInt(SCHEMA_VERSION_KEY, 1); + + if (schemaVersion > CURRENT_SCHEMA_VERSION) { + ChatHelper.logError( + "Rail type storage %s uses unsupported schema version %s. Loading known fields without rewriting it.", + FILE_PATH, + schemaVersion + ); + return; + } + + if (schemaVersion == CURRENT_SCHEMA_VERSION) + return; + + migrateVersionOneToTwo(config); + migrateVersionTwoToThree(config); + migrateVersionThreeToFour(config); + config.set(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION); + + try { + config.save(file); + } catch (IOException exception) { + ChatHelper.logError("Could not migrate rail types in %s.", exception, FILE_PATH); + } + } + + private void migrateVersionOneToTwo(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null) + continue; + + setDefault(section, KEY_OVERHEAD_POLES_ENABLED, false); + setDefault(section, KEY_OVERHEAD_POLE_BLOCK, XMaterial.LIGHT_GRAY_CONCRETE.name()); + setDefault(section, KEY_OVERHEAD_POLE_SPACING, RailType.DEFAULT_OVERHEAD_POLE_SPACING); + setDefault(section, KEY_OVERHEAD_POLE_OFFSET, RailType.DEFAULT_OVERHEAD_POLE_OFFSET); + setDefault(section, KEY_OVERHEAD_POLE_HEIGHT, RailType.DEFAULT_OVERHEAD_POLE_HEIGHT); + setDefault(section, KEY_OVERHEAD_WIRES_ENABLED, false); + setDefault(section, KEY_OVERHEAD_WIRE_BLOCK, XMaterial.IRON_BARS.name()); + setDefault(section, KEY_TRACK_SWITCHES_ENABLED, false); + } + } + + private void migrateVersionTwoToThree(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null) + continue; + + setDefault(section, KEY_OVERHEAD_SUPPORT_BLOCK, section.getString( + KEY_OVERHEAD_POLE_BLOCK, + XMaterial.LIGHT_GRAY_CONCRETE.name() + )); + } + } + + private void migrateVersionThreeToFour(YamlConfiguration config) { + ConfigurationSection typesSection = config.getConfigurationSection(TYPES_SECTION); + + if (typesSection == null) + return; + + for (String identifier : typesSection.getKeys(false)) { + ConfigurationSection section = typesSection.getConfigurationSection(identifier); + + if (section == null || section.contains(KEY_TRACK_SPACINGS)) + continue; + + int trackCount = section.getInt(KEY_TRACK_COUNT, RailType.DEFAULT_TRACK_COUNT); + int trackSpacing = section.getInt(KEY_TRACK_SPACING, RailType.DEFAULT_TRACK_SPACING); + section.set(KEY_TRACK_SPACINGS, Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing)); + } + } + + private void setDefault(ConfigurationSection section, String path, Object value) { + if (!section.contains(path)) + section.set(path, value); + } + + private RailType.Configuration toConfiguration(RailType railType) { + return new RailType.Configuration() + .identifier(railType.getIdentifier()) + .displayName(railType.getDisplayName()) + .icon(railType.getIcon()) + .railBlock(railType.getRailBlock()) + .blocksBelow(railType.getBlocksBelow()) + .sleeperBlock(railType.getSleeperBlock()) + .sleeperSpacing(railType.getSleeperSpacing()) + .trackCount(railType.getTrackCount()) + .trackSpacing(railType.getTrackSpacing()) + .trackSpacings(railType.getTrackSpacings()) + .overheadPolesEnabled(railType.isOverheadPolesEnabled()) + .overheadPoleBlock(railType.getOverheadPoleBlock()) + .overheadSupportBlock(railType.getOverheadSupportBlock()) + .overheadPoleSpacing(railType.getOverheadPoleSpacing()) + .overheadPoleOffset(railType.getOverheadPoleOffset()) + .overheadPoleHeight(railType.getOverheadPoleHeight()) + .overheadWiresEnabled(railType.isOverheadWiresEnabled()) + .overheadWireBlock(railType.getOverheadWireBlock()) + .trackSwitchesEnabled(railType.isTrackSwitchesEnabled()); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java similarity index 95% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java index c2dbd955..196433c0 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/PositionKey.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/PositionKey.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import org.bukkit.util.Vector; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java new file mode 100644 index 00000000..c48ee32f --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java @@ -0,0 +1,487 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import com.fastasyncworldedit.core.registry.state.PropertyKey; +import com.sk89q.worldedit.util.Direction; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import com.sk89q.worldedit.world.block.BlockTypes; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class RailBlockBuilder { + + private static final Direction DEFAULT_FACING = Direction.EAST; + private static final String FACING_PROPERTY = "facing"; + private static final String SHAPE_PROPERTY = "shape"; + private static final int FACING_SMOOTHING_RADIUS = 3; + // Sleeper ends stick out one block beyond the rails, which sit at offset 1 from the track center. + private static final int SLEEPER_SIDE_OFFSET = 2; + + private final RailTerrainResolver terrainResolver; + private final RailType railType; + private final BlockType railBlockType; + private final RailPreparationProgress preparationProgress; + private final long terrainAdjustedPercentage; + private final long buildFinishedPercentage; + + RailBlockBuilder( + RailTerrainResolver terrainResolver, + RailType railType, + RailPreparationProgress preparationProgress, + long terrainAdjustedPercentage, + long buildFinishedPercentage + ) { + this.terrainResolver = terrainResolver; + this.railType = railType; + this.railBlockType = getRailBlockType(railType); + this.preparationProgress = preparationProgress; + this.terrainAdjustedPercentage = terrainAdjustedPercentage; + this.buildFinishedPercentage = buildFinishedPercentage; + } + + Map build(List> railCenterPaths) { + Map railBlocks = new LinkedHashMap<>(); + Set centerPositions = getCenterPositions(railCenterPaths); + Map sideBlocks = new LinkedHashMap<>(); + int totalPathPoints = getTotalPathPointCount(railCenterPaths); + int processedPathPoints = 0; + + for (List railCenterPath : railCenterPaths) { + for (int index = 0; index < railCenterPath.size(); index++) { + Vector center = railCenterPath.get(index); + Direction facing = getSmoothedFacing(railCenterPath, index); + + for (RailSidePlacement sidePlacement : getSidePlacements(railCenterPath, index, facing)) + addSideBlock(sideBlocks, center, sidePlacement, centerPositions); + + processedPathPoints++; + preparationProgress.update(preparationProgress.scale(processedPathPoints, totalPathPoints, terrainAdjustedPercentage, 86L)); + } + } + + int processedSideBlocks = 0; + + for (RailSideBlock sideBlock : sideBlocks.values()) { + railBlocks.put(sideBlock.key(), createRailBlockState(sideBlock, sideBlocks)); + processedSideBlocks++; + preparationProgress.update(preparationProgress.scale(processedSideBlocks, sideBlocks.size(), 86L, 89L)); + } + + if (railType.hasSleepers()) + for (List railCenterPath : railCenterPaths) + addSleeperBlocks(railBlocks, railCenterPath, centerPositions); + + int processedCenterPoints = 0; + + for (List railCenterPath : railCenterPaths) { + for (int index = 0; index < railCenterPath.size(); index++) { + Vector center = railCenterPath.get(index); + railBlocks.put(PositionKey.from(center), createCenterBlockState(center, isSleeperPoint(index))); + processedCenterPoints++; + preparationProgress.update(preparationProgress.scale(processedCenterPoints, totalPathPoints, 89L, buildFinishedPercentage)); + } + } + + return railBlocks; + } + + private boolean isSleeperPoint(int index) { + return railType.hasSleepers() && index % railType.getSleeperSpacing() == 0; + } + + private void addSleeperBlocks(Map railBlocks, List path, Set centerPositions) { + BlockState sleeperBlockState = GeneratorUtils.getBlockState(railType.getSleeperBlock()); + + for (int index = 0; index < path.size(); index++) { + if (!isSleeperPoint(index)) + continue; + + Vector center = path.get(index); + RailStep step = getRailStep(path, index, new RailStep(1, 0)); + RailStep perpendicularStep = new RailStep(-step.dz(), step.dx()); + + addSleeperBlock(railBlocks, center, perpendicularStep, SLEEPER_SIDE_OFFSET, sleeperBlockState, centerPositions); + addSleeperBlock(railBlocks, center, perpendicularStep, -SLEEPER_SIDE_OFFSET, sleeperBlockState, centerPositions); + } + } + + private void addSleeperBlock( + Map railBlocks, + Vector center, + RailStep perpendicularStep, + int offset, + BlockState sleeperBlockState, + Set centerPositions + ) { + if (perpendicularStep.dx() == 0 && perpendicularStep.dz() == 0) + return; + + int x = center.getBlockX() + perpendicularStep.dx() * offset; + int z = center.getBlockZ() + perpendicularStep.dz() * offset; + int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); + + PositionKey key = PositionKey.of(x, y, z); + + // Rails and track centers take precedence over sleeper ends. + if (centerPositions.contains(key) || railBlocks.containsKey(key)) + return; + + railBlocks.put(key, sleeperBlockState); + } + + private int getTotalPathPointCount(List> railCenterPaths) { + int totalPathPoints = 0; + + for (List railCenterPath : railCenterPaths) + totalPathPoints += railCenterPath.size(); + + return Math.max(1, totalPathPoints); + } + + private static List getSidePlacements(List path, int index, Direction facing) { + List placements = new ArrayList<>(); + Vector center = path.get(index); + RailStep previousStep = index > 0 ? getStep(path.get(index - 1), center) : null; + RailStep nextStep = index < path.size() - 1 ? getStep(center, path.get(index + 1)) : null; + + addSidePlacements(placements, getRailStep(path, index, new RailStep(1, 0)), facing); + + if (previousStep != null) + addSidePlacements(placements, previousStep, facing); + + if (nextStep != null) + addSidePlacements(placements, nextStep, facing); + + return placements; + } + + private static void addSidePlacements(List placements, RailStep step, Direction facing) { + if (step.dx() != 0 && step.dz() != 0) { + addSidePlacement(placements, new RailStep(step.dx(), 0), facing); + addSidePlacement(placements, new RailStep(0, step.dz()), facing); + return; + } + + if (step.dx() != 0) { + addSidePlacement(placements, new RailStep(0, 1), facing); + addSidePlacement(placements, new RailStep(0, -1), facing); + return; + } + + addSidePlacement(placements, new RailStep(1, 0), facing); + addSidePlacement(placements, new RailStep(-1, 0), facing); + } + + private static Direction getSmoothedFacing(List path, int index) { + int fromIndex = Math.max(0, index - FACING_SMOOTHING_RADIUS); + int toIndex = Math.min(path.size() - 1, index + FACING_SMOOTHING_RADIUS); + Vector from = path.get(fromIndex); + Vector to = path.get(toIndex); + int dx = to.getBlockX() - from.getBlockX(); + int dz = to.getBlockZ() - from.getBlockZ(); + + if (Math.abs(dx) == Math.abs(dz)) { + Vector pathStart = path.getFirst(); + Vector pathEnd = path.getLast(); + int totalDx = pathEnd.getBlockX() - pathStart.getBlockX(); + int totalDz = pathEnd.getBlockZ() - pathStart.getBlockZ(); + + if (Math.abs(totalDx) != Math.abs(totalDz)) { + dx = totalDx; + dz = totalDz; + } + } + + if (Math.abs(dx) >= Math.abs(dz) && dx != 0) + return GeneratorUtils.getFacing(Integer.compare(dx, 0), 0, DEFAULT_FACING); + + if (dz != 0) + return GeneratorUtils.getFacing(0, Integer.compare(dz, 0), DEFAULT_FACING); + + return DEFAULT_FACING; + } + + private static void addSidePlacement(List placements, RailStep offset, Direction facing) { + for (RailSidePlacement placement : placements) { + if (placement.offset().equals(offset)) return; + } + + placements.add(new RailSidePlacement(offset, facing)); + } + + private void addSideBlock( + Map sideBlocks, + Vector center, + RailSidePlacement sidePlacement, + Set centerPositions + ) { + RailStep sideOffset = sidePlacement.offset(); + + if (sideOffset.dx() == 0 && sideOffset.dz() == 0) + return; + + int x = center.getBlockX() + sideOffset.dx(); + int z = center.getBlockZ() + sideOffset.dz(); + int y = terrainResolver.getNearestRailSurfaceY(x, z, center.getBlockY()); + + PositionKey key = PositionKey.of(x, y, z); + + if (centerPositions.contains(key)) + return; + + sideBlocks + .computeIfAbsent(key, ignored -> new RailSideBlock(key, DEFAULT_FACING)) + .addFacing(sidePlacement.facing()); + } + + private Direction resolveSideBlockFacing(RailSideBlock sideBlock, Map sideBlocks) { + RailConnections connections = getConnections(sideBlock.key(), sideBlocks); + int xConnections = (connections.east() ? 1 : 0) + (connections.west() ? 1 : 0); + int zConnections = (connections.south() ? 1 : 0) + (connections.north() ? 1 : 0); + Direction preferredFacing = sideBlock.getPreferredFacing(); + + if (xConnections > zConnections) + return resolveAxisFacing( + preferredFacing, + Direction.EAST, + Direction.WEST, + connections.east(), + connections.west() + ); + + if (zConnections > xConnections) + return resolveAxisFacing( + preferredFacing, + Direction.SOUTH, + Direction.NORTH, + connections.south(), + connections.north() + ); + + return preferredFacing; + } + + private Direction resolveAxisFacing( + Direction preferredFacing, + Direction positiveFacing, + Direction negativeFacing, + boolean hasPositiveNeighbor, + boolean hasNegativeNeighbor + ) { + if (preferredFacing == positiveFacing && hasPositiveNeighbor || preferredFacing == negativeFacing && hasNegativeNeighbor) + return preferredFacing; + + if (hasPositiveNeighbor && !hasNegativeNeighbor) + return positiveFacing; + + if (hasNegativeNeighbor && !hasPositiveNeighbor) + return negativeFacing; + + return preferredFacing == negativeFacing ? negativeFacing : positiveFacing; + } + + private Set getCenterPositions(List> railCenterPaths) { + Set centerPositions = new HashSet<>(); + + for (List railCenterPath : railCenterPaths) + for (Vector center : railCenterPath) + centerPositions.add(PositionKey.from(center)); + + return centerPositions; + } + + private static RailStep getRailStep(List path, int index, RailStep fallbackStep) { + RailStep previousStep = index > 0 ? getStep(path.get(index - 1), path.get(index)) : null; + RailStep nextStep = index < path.size() - 1 ? getStep(path.get(index), path.get(index + 1)) : null; + + if (previousStep != null && nextStep != null) { + int dx = Integer.compare(previousStep.dx() + nextStep.dx(), 0); + int dz = Integer.compare(previousStep.dz() + nextStep.dz(), 0); + + if (dx != 0 || dz != 0) return new RailStep(dx, dz); + } + + if (nextStep != null) return nextStep; + + if (previousStep != null) return previousStep; + + return fallbackStep; + } + + private static RailStep getStep(Vector from, Vector to) { + int dx = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int dz = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + + if (dx == 0 && dz == 0) return null; + + return new RailStep(dx, dz); + } + + private BlockState createCenterBlockState(Vector position, boolean sleeperPoint) { + if (sleeperPoint) + return GeneratorUtils.getBlockState(railType.getSleeperBlock()); + + List blocksBelow = railType.getBlocksBelow(); + int index = Math.floorMod( + position.getBlockX() * 31 + position.getBlockY() * 23 + position.getBlockZ() * 17, + blocksBelow.size() + ); + + return GeneratorUtils.getBlockState(blocksBelow.get(index)); + } + + private BlockState createRailBlockState( + RailSideBlock sideBlock, + Map sideBlocks + ) { + Direction preferredDirection = sideBlock.getPreferredFacing(); + + if (railBlockType.getPropertyMap().containsKey(SHAPE_PROPERTY)) + return createRailShapeBlockState( + resolveSideBlockFacing(sideBlock, sideBlocks), + getConnections(sideBlock.key(), sideBlocks) + ); + + if (!railBlockType.getPropertyMap().containsKey(FACING_PROPERTY)) + return railBlockType.getDefaultState(); + + return GeneratorUtils.getBlockStateWithFacing(railBlockType, preferredDirection); + } + + private BlockState createRailShapeBlockState(Direction preferredDirection, RailConnections connections) { + String shape = resolveRailShape(preferredDirection, connections); + + try { + return railBlockType.getDefaultState().with( + PropertyKey.SHAPE, + shape + ); + } catch (IllegalArgumentException exception) { + return railBlockType.getDefaultState().with( + PropertyKey.SHAPE, + getStraightRailShape(preferredDirection) + ); + } + } + + private String resolveRailShape(Direction preferredDirection, RailConnections connections) { + String ascendingShape = getAscendingRailShape(connections); + + if (ascendingShape != null) + return ascendingShape; + + String cornerShape = getCornerRailShape(connections); + + if (cornerShape != null) + return cornerShape; + + return getAxisRailShape(preferredDirection, connections); + } + + private @Nullable String getAscendingRailShape(RailConnections connections) { + if (isRaised(connections.eastHeightDifference())) + return "ascending_east"; + + if (isRaised(connections.westHeightDifference())) + return "ascending_west"; + + if (isRaised(connections.southHeightDifference())) + return "ascending_south"; + + if (isRaised(connections.northHeightDifference())) + return "ascending_north"; + + return null; + } + + private @Nullable String getCornerRailShape(RailConnections connections) { + if (connections.connectionCount() != 2) + return null; + + if (connections.north() && connections.east()) + return "north_east"; + + if (connections.north() && connections.west()) + return "north_west"; + + if (connections.south() && connections.east()) + return "south_east"; + + return connections.south() && connections.west() ? "south_west" : null; + } + + private String getAxisRailShape(Direction preferredDirection, RailConnections connections) { + int xConnections = (connections.east() ? 1 : 0) + (connections.west() ? 1 : 0); + int zConnections = (connections.south() ? 1 : 0) + (connections.north() ? 1 : 0); + + if (xConnections > zConnections) + return "east_west"; + + if (zConnections > xConnections) + return "north_south"; + + return getStraightRailShape(preferredDirection); + } + + private String getStraightRailShape(Direction direction) { + return direction == Direction.NORTH || direction == Direction.SOUTH ? "north_south" : "east_west"; + } + + private boolean isRaised(@Nullable Integer heightDifference) { + return heightDifference != null && heightDifference > 0; + } + + private RailConnections getConnections( + PositionKey key, + Map sideBlocks + ) { + return new RailConnections( + getNeighborHeightDifference(key, 1, 0, sideBlocks), + getNeighborHeightDifference(key, -1, 0, sideBlocks), + getNeighborHeightDifference(key, 0, 1, sideBlocks), + getNeighborHeightDifference(key, 0, -1, sideBlocks) + ); + } + + private @Nullable Integer getNeighborHeightDifference( + PositionKey key, + int xOffset, + int zOffset, + Map sideBlocks + ) { + for (int heightDifference = 0; heightDifference <= 1; heightDifference++) { + if (sideBlocks.containsKey(PositionKey.of( + key.x() + xOffset, + key.y() + heightDifference, + key.z() + zOffset + ))) + return heightDifference; + + if (heightDifference > 0 && sideBlocks.containsKey(PositionKey.of( + key.x() + xOffset, + key.y() - heightDifference, + key.z() + zOffset + ))) + return -heightDifference; + } + + return null; + } + + private BlockType getRailBlockType(RailType railType) { + BlockType blockType = Item.convertXMaterialToWEBlockType(railType.getRailBlock()); + + return blockType == null ? BlockTypes.ANVIL : blockType; + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java similarity index 89% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java index ec459ab1..c57a6f97 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailColumnKey.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailColumnKey.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; record RailColumnKey(int x, int z) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java new file mode 100644 index 00000000..dcadff9d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailConnections.java @@ -0,0 +1,31 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.jspecify.annotations.Nullable; + +record RailConnections( + @Nullable Integer eastHeightDifference, + @Nullable Integer westHeightDifference, + @Nullable Integer southHeightDifference, + @Nullable Integer northHeightDifference +) { + + boolean east() { + return eastHeightDifference != null; + } + + boolean west() { + return westHeightDifference != null; + } + + boolean south() { + return southHeightDifference != null; + } + + boolean north() { + return northHeightDifference != null; + } + + int connectionCount() { + return (east() ? 1 : 0) + (west() ? 1 : 0) + (south() ? 1 : 0) + (north() ? 1 : 0); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java new file mode 100644 index 00000000..72c05da5 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLanePathBuilder.java @@ -0,0 +1,166 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import org.bukkit.util.Vector; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class RailLanePathBuilder { + + private final List controlPoints; + private final RailTerrainResolver terrainResolver; + private final int railLaneCount; + private final List railLaneSpacings; + + RailLanePathBuilder( + List controlPoints, + RailTerrainResolver terrainResolver, + int railLaneCount, + List railLaneSpacings + ) { + this.controlPoints = controlPoints; + this.terrainResolver = terrainResolver; + this.railLaneCount = railLaneCount; + this.railLaneSpacings = railLaneSpacings; + } + + List> createRailCenterPaths(List path) { + if (railLaneCount <= 1) + return List.of(path); + + List> railCenterPaths = new ArrayList<>(); + List offsets = createLaneOffsets(); + + // Lanes are spread symmetrically around the selected path. Even track counts + // have no lane on the path itself, only shifted lanes on both sides. + for (int laneIndex = 0; laneIndex < railLaneCount; laneIndex++) { + int offset = offsets.get(laneIndex); + + if (offset == 0) { + railCenterPaths.add(path); + continue; + } + + List shiftedLane = createShiftedRailLane(Math.abs(offset), offset > 0 ? 1 : -1); + + if (shiftedLane.size() >= 2) + railCenterPaths.add(shiftedLane); + } + + return railCenterPaths; + } + + private List createLaneOffsets() { + List positions = new ArrayList<>(railLaneCount); + int position = 0; + positions.add(position); + + for (int spacing : railLaneSpacings) { + position += spacing; + positions.add(position); + } + + double center = position / 2.0D; + return positions.stream().map(value -> (int) Math.round(value - center)).toList(); + } + + private List createShiftedRailLane(int distance, int sideSign) { + List shiftedControlPoints = createShiftedControlPoints(distance, sideSign); + + if (shiftedControlPoints.size() < 2) + return Collections.emptyList(); + + List shiftedPath = createCenterPath(shiftedControlPoints); + + if (shiftedPath.size() < 2) + return Collections.emptyList(); + + terrainResolver.adjustPathToTerrain(shiftedPath); + return shiftedPath; + } + + private List createShiftedControlPoints(int distance, int sideSign) { + List shiftedControlPoints = new ArrayList<>(); + + for (int index = 0; index < controlPoints.size(); index++) { + Vector basePoint = controlPoints.get(index); + Vector offset = getMiterOffset(index, distance, sideSign); + + if (offset.lengthSquared() != 0) + addIfDifferentFromPrevious(shiftedControlPoints, new Vector( + basePoint.getBlockX() + (int) Math.round(offset.getX()), + basePoint.getBlockY(), + basePoint.getBlockZ() + (int) Math.round(offset.getZ()) + )); + } + + return shiftedControlPoints; + } + + private Vector getMiterOffset(int index, int distance, int sideSign) { + Vector previousDirection = index > 0 + ? normalizedHorizontalDirection(controlPoints.get(index - 1), controlPoints.get(index)) + : new Vector(); + Vector nextDirection = index < controlPoints.size() - 1 + ? normalizedHorizontalDirection(controlPoints.get(index), controlPoints.get(index + 1)) + : new Vector(); + + if (previousDirection.lengthSquared() == 0) + return perpendicular(nextDirection, sideSign).multiply(distance); + + if (nextDirection.lengthSquared() == 0) + return perpendicular(previousDirection, sideSign).multiply(distance); + + Vector previousNormal = perpendicular(previousDirection, sideSign); + Vector nextNormal = perpendicular(nextDirection, sideSign); + Vector miter = previousNormal.clone().add(nextNormal); + + if (miter.lengthSquared() < 0.0001D) + return nextNormal.multiply(distance); + + miter.normalize(); + double denominator = miter.dot(nextNormal); + + if (Math.abs(denominator) < 0.25D) + return nextNormal.multiply(distance); + + // The miter keeps both adjacent segments at the requested perpendicular + // distance. Limit extreme spikes at very sharp control-point corners. + double miterLength = Math.min(distance / denominator, distance * 4.0D); + return miter.multiply(miterLength); + } + + private Vector normalizedHorizontalDirection(Vector from, Vector to) { + Vector direction = new Vector( + to.getBlockX() - from.getBlockX(), + 0, + to.getBlockZ() - from.getBlockZ() + ); + + return direction.lengthSquared() == 0 ? direction : direction.normalize(); + } + + private Vector perpendicular(Vector direction, int sideSign) { + return new Vector(-direction.getZ() * sideSign, 0, direction.getX() * sideSign); + } + + private void addIfDifferentFromPrevious(List points, Vector point) { + if (points.isEmpty()) { + points.add(point); + return; + } + + Vector previousPoint = points.get(points.size() - 1); + + if (previousPoint.getBlockX() == point.getBlockX() && previousPoint.getBlockZ() == point.getBlockZ()) + return; + + points.add(point); + } + + private List createCenterPath(List points) { + return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java similarity index 54% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java index 860fd0ae..e051f902 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailLimits.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailLimits.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.utils.io.ConfigPaths; @@ -15,15 +15,15 @@ record RailLimits( ) { // Default amount of WorldEdit control points accepted before path interpolation starts. - private static final int DEFAULT_MAX_CONTROL_POINTS = 1_000; + private static final int DEFAULT_MAX_CONTROL_POINTS = 2_000; // Default amount of interpolated center-path blocks a rail may contain. - private static final int DEFAULT_MAX_PATH_POINTS = 24_000; + private static final int DEFAULT_MAX_PATH_POINTS = 75_000; // Default amount of final center, side and support blocks queued for placement. - private static final int DEFAULT_MAX_BLOCK_PLACEMENTS = 150_000; + private static final int DEFAULT_MAX_BLOCK_PLACEMENTS = 300_000; // Default volume of the prepared terrain lookup region around the selected rail path. - private static final long DEFAULT_MAX_PREPARED_REGION_VOLUME = 1_500_000L; + private static final long DEFAULT_MAX_PREPARED_REGION_VOLUME = 6_000_000L; // Default maximum width, height or depth of the prepared terrain lookup region. - private static final int DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH = 1_024; + private static final int DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH = 2_048; // Default amount of block changes applied per scheduler tick during execution. private static final int DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE = 750; @@ -44,20 +44,56 @@ static RailLimits fromConfig() { FileConfiguration config = BuildTeamTools.getInstance().getConfig(ConfigUtil.GENERATOR); return new RailLimits( - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_CONTROL_POINTS, DEFAULT_MAX_CONTROL_POINTS, 2, MAX_CONTROL_POINTS), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_PATH_POINTS, DEFAULT_MAX_PATH_POINTS, 2, MAX_PATH_POINTS), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_BLOCK_PLACEMENTS, DEFAULT_MAX_BLOCK_PLACEMENTS, 1, MAX_BLOCK_PLACEMENTS), - getBoundedLong(config, ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_VOLUME, DEFAULT_MAX_PREPARED_REGION_VOLUME, 1L, MAX_PREPARED_REGION_VOLUME), - getBoundedInt(config, ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_AXIS_LENGTH, DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH, 1, MAX_PREPARED_REGION_AXIS_LENGTH), - getBoundedInt(config, ConfigPaths.Generator.Rail.BLOCK_PLACEMENT_BATCH_SIZE, DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE, 1, MAX_BLOCK_PLACEMENT_BATCH_SIZE) + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_CONTROL_POINTS, + DEFAULT_MAX_CONTROL_POINTS, + 2, + MAX_CONTROL_POINTS + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_PATH_POINTS, + DEFAULT_MAX_PATH_POINTS, + 2, + MAX_PATH_POINTS + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_BLOCK_PLACEMENTS, + DEFAULT_MAX_BLOCK_PLACEMENTS, + 1, + MAX_BLOCK_PLACEMENTS + ), + getBoundedLong( + config, + ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_VOLUME, + DEFAULT_MAX_PREPARED_REGION_VOLUME, + 1L, + MAX_PREPARED_REGION_VOLUME + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.MAX_PREPARED_REGION_AXIS_LENGTH, + DEFAULT_MAX_PREPARED_REGION_AXIS_LENGTH, + 1, + MAX_PREPARED_REGION_AXIS_LENGTH + ), + getBoundedInt( + config, + ConfigPaths.Generator.Rail.BLOCK_PLACEMENT_BATCH_SIZE, + DEFAULT_BLOCK_PLACEMENT_BATCH_SIZE, + 1, + MAX_BLOCK_PLACEMENT_BATCH_SIZE + ) ); } private static int getBoundedInt(FileConfiguration config, String path, int fallback, int minimum, int maximum) { - return Math.max(minimum, Math.min(maximum, config.getInt(path, fallback))); + return Math.clamp(config.getInt(path, fallback), minimum, maximum); } private static long getBoundedLong(FileConfiguration config, String path, long fallback, long minimum, long maximum) { - return Math.max(minimum, Math.min(maximum, config.getLong(path, fallback))); + return Math.clamp(config.getLong(path, fallback), minimum, maximum); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java new file mode 100644 index 00000000..f2e3c882 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java @@ -0,0 +1,396 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import com.fastasyncworldedit.core.registry.state.PropertyKey; +import com.sk89q.worldedit.util.Direction; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.bukkit.util.Vector; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class RailOverheadBuilder { + + private static final Direction DEFAULT_FACING = Direction.EAST; + private static final String FACING_PROPERTY = "facing"; + private static final int MAX_POLE_COLLISION_SHIFTS = RailType.MAX_TRACK_COUNT * RailType.MAX_TRACK_SPACING; + + private final RailTerrainResolver terrainResolver; + private final RailType railType; + + RailOverheadBuilder(RailTerrainResolver terrainResolver, RailType railType) { + this.terrainResolver = terrainResolver; + this.railType = railType; + } + + void addTo(Map blocks, List> railCenterPaths) { + int overheadY = getOverheadY(railCenterPaths); + Set trackFootprint = getHorizontalFootprint(blocks.keySet()); + + if (railType.hasOverheadWires()) + addWires(blocks, railCenterPaths, overheadY); + + if (!railType.hasOverheadPoles() || railCenterPaths.isEmpty()) + return; + + addPortals( + blocks, + railCenterPaths.getFirst(), + railCenterPaths.getLast(), + overheadY, + trackFootprint + ); + } + + private Set getHorizontalFootprint(Set positions) { + Set footprint = new HashSet<>(); + + for (PositionKey position : positions) + footprint.add(new HorizontalPosition(position.x(), position.z())); + + return footprint; + } + + private int getOverheadY(List> railCenterPaths) { + int maxRailY = Integer.MIN_VALUE; + + for (List path : railCenterPaths) + for (Vector point : path) + maxRailY = Math.max(maxRailY, point.getBlockY()); + + return (maxRailY == Integer.MIN_VALUE ? 0 : maxRailY) + railType.getOverheadPoleHeight(); + } + + private void addWires(Map blocks, List> railCenterPaths, int overheadY) { + XMaterial wireBlock = railType.getOverheadWireBlock(); + + if (wireBlock == null) + return; + + BlockType wireBlockType = Item.convertXMaterialToWEBlockType(wireBlock); + + if (wireBlockType == null) + return; + + for (List path : railCenterPaths) { + List wirePath = createOverheadPath(path, overheadY); + addPathBlocks(blocks, wirePath, wireBlockType, false); + } + } + + private void addPortals( + Map blocks, + List leftPath, + List rightPath, + int overheadY, + Set trackFootprint + ) { + XMaterial poleBlock = railType.getOverheadPoleBlock(); + XMaterial supportBlock = railType.getOverheadSupportBlock(); + + if (poleBlock == null || supportBlock == null) + return; + + BlockState poleState = GeneratorUtils.getBlockState(poleBlock); + BlockType supportBlockType = Item.convertXMaterialToWEBlockType(supportBlock); + + if (supportBlockType == null) + return; + + for (int index = 0; index < leftPath.size(); index += railType.getOverheadPoleSpacing()) { + Vector leftCenter = leftPath.get(index); + int rightIndex = getProportionalIndex(index, leftPath.size(), rightPath.size()); + Vector rightCenter = rightPath.get(rightIndex); + PortalPole leftPole = createPole(leftPath, index, leftCenter, -1, trackFootprint); + PortalPole rightPole = createPole(rightPath, rightIndex, rightCenter, 1, trackFootprint); + + if (leftPole == null || rightPole == null) + continue; + + addPole(blocks, leftPole.x(), leftPole.z(), leftPole.surfaceY(), overheadY, poleState); + addPole(blocks, rightPole.x(), rightPole.z(), rightPole.surfaceY(), overheadY, poleState); + addPortalSupport(blocks, leftPole.x(), overheadY + 1, leftPole.z(), rightPole.x(), rightPole.z(), supportBlockType); + } + } + + private int getProportionalIndex(int sourceIndex, int sourceSize, int targetSize) { + if (sourceSize <= 1 || targetSize <= 1) + return 0; + + double progress = sourceIndex / (double) (sourceSize - 1); + return Math.clamp((int) Math.round(progress * (targetSize - 1)), 0, targetSize - 1); + } + + private @Nullable PortalPole createPole( + List path, + int index, + Vector center, + int sideSign, + Set trackFootprint + ) { + RailStep perpendicular = getPerpendicularStep(path, index, sideSign); + int configuredDistance = railType.getOverheadPoleOffset(); + + for (int shift = 0; shift <= MAX_POLE_COLLISION_SHIFTS; shift++) { + int distance = configuredDistance + shift; + int poleX = center.getBlockX() + perpendicular.dx() * distance; + int poleZ = center.getBlockZ() + perpendicular.dz() * distance; + + if (trackFootprint.contains(new HorizontalPosition(poleX, poleZ))) + continue; + + int surfaceY = terrainResolver.getNearestRailSurfaceY(poleX, poleZ, center.getBlockY()); + return new PortalPole(poleX, poleZ, surfaceY); + } + + return null; + } + + private void addPole( + Map blocks, + int poleX, + int poleZ, + int surfaceY, + int topY, + BlockState poleState + ) { + for (int y = Math.min(surfaceY, topY); y <= topY; y++) + blocks.putIfAbsent(PositionKey.of(poleX, y, poleZ), poleState); + } + + private void addPortalSupport( + Map blocks, + int startX, + int topY, + int startZ, + int endX, + int endZ, + BlockType supportBlockType + ) { + Vector start = new Vector(startX, topY, startZ); + Vector end = new Vector(endX, topY, endZ); + addPathBlocks(blocks, createOrthogonalPath(start, end), supportBlockType, true); + } + + private RailStep getPerpendicularStep(List path, int index, int sideSign) { + RailStep direction = getStep(path, index); + return new RailStep(-direction.dz() * sideSign, direction.dx() * sideSign); + } + + private List createOverheadPath(List path, int overheadY) { + List overheadPoints = path.stream() + .map(point -> new Vector(point.getBlockX(), overheadY, point.getBlockZ())) + .toList(); + + return GeneratorUtils.createShortestBlockPath(overheadPoints); + } + + private List createOrthogonalPath(Vector start, Vector end) { + return GeneratorUtils.createShortestBlockPath(List.of(start, end)); + } + + private void addPathBlocks( + Map blocks, + List path, + BlockType blockType, + boolean overwrite + ) { + Set positions = createConnectedPathPositions(path); + + for (PositionKey position : positions) { + BlockState blockState = createPathState(blockType, position, positions); + + if (overwrite) + blocks.put(position, blockState); + else + blocks.computeIfAbsent(position, ignored -> blockState); + } + } + + private Set createConnectedPathPositions(List path) { + Set positions = new LinkedHashSet<>(); + + for (Vector point : path) + positions.add(PositionKey.from(point)); + + addDiagonalConnectorPositions(positions, path); + return positions; + } + + /** + * Minecraft blocks only connect over cardinal edges. The rasterizer also + * emits diagonal steps, so bridge each one with a single orthogonal block. + * Continuing the preceding/following cardinal direction avoids bulky 2x2 + * corners while keeping panes, bars, fences, walls and full blocks connected. + */ + private void addDiagonalConnectorPositions(Set positions, List path) { + for (int index = 1; index < path.size(); index++) { + Vector previous = path.get(index - 1); + Vector current = path.get(index); + RailStep step = getStepBetween(previous, current); + + if (step.dx() == 0 || step.dz() == 0) + continue; + + positions.add(PositionKey.from(selectDiagonalBridge(path, index, previous, current, step))); + } + } + + private Vector selectDiagonalBridge( + List path, + int index, + Vector previous, + Vector current, + RailStep diagonalStep + ) { + Vector xFirst = new Vector( + previous.getBlockX() + diagonalStep.dx(), + current.getBlockY(), + previous.getBlockZ() + ); + Vector zFirst = new Vector( + previous.getBlockX(), + current.getBlockY(), + previous.getBlockZ() + diagonalStep.dz() + ); + + if (index > 1) { + RailStep previousStep = getStepBetween(path.get(index - 2), previous); + + if (previousStep.dx() != 0 && previousStep.dz() == 0) + return xFirst; + + if (previousStep.dz() != 0 && previousStep.dx() == 0) + return zFirst; + } + + if (index < path.size() - 1) { + RailStep nextStep = getStepBetween(current, path.get(index + 1)); + + if (nextStep.dx() != 0 && nextStep.dz() == 0) + return zFirst; + + if (nextStep.dz() != 0 && nextStep.dx() == 0) + return xFirst; + } + + return index % 2 == 0 ? xFirst : zFirst; + } + + private BlockState createPathState(BlockType blockType, PositionKey position, Set positions) { + Set connections = EnumSet.noneOf(Direction.class); + addConnectionIfPresent(connections, positions, position, Direction.NORTH, 0, -1); + addConnectionIfPresent(connections, positions, position, Direction.EAST, 1, 0); + addConnectionIfPresent(connections, positions, position, Direction.SOUTH, 0, 1); + addConnectionIfPresent(connections, positions, position, Direction.WEST, -1, 0); + + if (connections.isEmpty()) + connections.add(DEFAULT_FACING); + + return createConnectedState(blockType, connections); + } + + private void addConnectionIfPresent( + Set connections, + Set positions, + PositionKey position, + Direction direction, + int xOffset, + int zOffset + ) { + if (positions.contains(PositionKey.of(position.x() + xOffset, position.y(), position.z() + zOffset))) + connections.add(direction); + } + + private BlockState createConnectedState(BlockType blockType, Set connections) { + BlockState blockState = blockType.getDefaultState(); + + if (!hasDirectionalConnectionProperties(blockType)) + return createDirectionalSupportState(blockType, connections.iterator().next()); + + blockState = applyConnection(blockState, blockType, PropertyKey.NORTH, connections.contains(Direction.NORTH)); + blockState = applyConnection(blockState, blockType, PropertyKey.EAST, connections.contains(Direction.EAST)); + blockState = applyConnection(blockState, blockType, PropertyKey.SOUTH, connections.contains(Direction.SOUTH)); + blockState = applyConnection(blockState, blockType, PropertyKey.WEST, connections.contains(Direction.WEST)); + + if (hasProperty(blockType, PropertyKey.UP)) + blockState = blockState.with(PropertyKey.UP, true); + + return blockState; + } + + private BlockState createDirectionalSupportState(BlockType blockType, Direction direction) { + BlockState blockState = createDirectionalState(blockType, direction); + + if (!hasProperty(blockType, PropertyKey.TYPE)) + return blockState; + + try { + return blockState.with(PropertyKey.TYPE, "bottom"); + } catch (IllegalArgumentException exception) { + return blockState; + } + } + + private BlockState applyConnection(BlockState blockState, BlockType blockType, PropertyKey key, boolean connected) { + if (!hasProperty(blockType, key)) + return blockState; + + try { + return blockState.with(key, connected); + } catch (IllegalArgumentException exception) { + return blockState.with(key, connected ? "low" : "none"); + } + } + + private boolean hasDirectionalConnectionProperties(BlockType blockType) { + return hasProperty(blockType, PropertyKey.NORTH) + || hasProperty(blockType, PropertyKey.EAST) + || hasProperty(blockType, PropertyKey.SOUTH) + || hasProperty(blockType, PropertyKey.WEST); + } + + private boolean hasProperty(BlockType blockType, PropertyKey key) { + return blockType.getPropertyMap().containsKey(key.getName()); + } + + private RailStep getStepBetween(Vector from, Vector to) { + int xDirection = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int zDirection = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + return new RailStep(xDirection, zDirection); + } + + private RailStep getStep(List path, int index) { + Vector from = index > 0 ? path.get(index - 1) : path.get(index); + Vector to = index < path.size() - 1 ? path.get(index + 1) : path.get(index); + int xDirection = Integer.compare(to.getBlockX() - from.getBlockX(), 0); + int zDirection = Integer.compare(to.getBlockZ() - from.getBlockZ(), 0); + + if (xDirection == 0 && zDirection == 0) + return new RailStep(1, 0); + + return new RailStep(xDirection, zDirection); + } + + private BlockState createDirectionalState(BlockType blockType, Direction direction) { + if (!blockType.getPropertyMap().containsKey(FACING_PROPERTY)) + return blockType.getDefaultState(); + + return GeneratorUtils.getBlockStateWithFacing(blockType, direction); + } + + private record PortalPole(int x, int z, int surfaceY) { + } + + private record HorizontalPosition(int x, int z) { + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java new file mode 100644 index 00000000..af365883 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPathOverlapValidator.java @@ -0,0 +1,117 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.bukkit.util.Vector; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class RailPathOverlapValidator { + + private static final List VERTICAL_OFFSETS = List.of(-1, 0, 1); + + boolean hasOverlap(List> railCenterPaths) { + Set previousLanePositions = new HashSet<>(); + Set previousLaneDiagonals = new HashSet<>(); + + for (List railCenterPath : railCenterPaths) { + LaneGeometry currentLane = createLaneGeometry(railCenterPath); + + if (currentLane == null + || overlapsPreviousLane(currentLane, previousLanePositions, previousLaneDiagonals)) + return true; + + previousLanePositions.addAll(currentLane.centers()); + previousLaneDiagonals.addAll(currentLane.diagonals()); + } + + return false; + } + + private LaneGeometry createLaneGeometry(List railCenterPath) { + Set centers = new HashSet<>(); + Set diagonals = new HashSet<>(); + + for (int index = 0; index < railCenterPath.size(); index++) { + if (!centers.add(PositionKey.from(railCenterPath.get(index)))) + return null; + + if (index == 0) + continue; + + DiagonalStep diagonal = createDiagonalStep(railCenterPath.get(index - 1), railCenterPath.get(index)); + + if (diagonal != null && crossesDiagonal(diagonal, diagonals)) + return null; + + if (diagonal != null) + diagonals.add(diagonal); + } + + return new LaneGeometry(centers, diagonals); + } + + private boolean overlapsPreviousLane(LaneGeometry lane, + Set previousLanePositions, + Set previousLaneDiagonals) { + for (PositionKey center : lane.centers()) { + if (crossesPreviousLane(center, previousLanePositions)) + return true; + } + + for (DiagonalStep diagonal : lane.diagonals()) { + if (crossesDiagonal(diagonal, previousLaneDiagonals)) + return true; + } + + return false; + } + + private DiagonalStep createDiagonalStep(Vector from, Vector to) { + int dx = to.getBlockX() - from.getBlockX(); + int dz = to.getBlockZ() - from.getBlockZ(); + + if (Math.abs(dx) != 1 || Math.abs(dz) != 1) + return null; + + return new DiagonalStep( + Math.min(from.getBlockX(), to.getBlockX()), + Math.min(from.getBlockZ(), to.getBlockZ()), + Math.min(from.getBlockY(), to.getBlockY()), + dx == dz + ); + } + + private boolean crossesDiagonal(DiagonalStep diagonal, Set otherDiagonals) { + for (int yOffset : VERTICAL_OFFSETS) { + DiagonalStep crossing = new DiagonalStep( + diagonal.x(), + diagonal.z(), + diagonal.y() + yOffset, + !diagonal.positiveSlope() + ); + + if (otherDiagonals.contains(crossing)) + return true; + } + + return false; + } + + private boolean crossesPreviousLane(PositionKey center, Set previousLanePositions) { + for (int yOffset : VERTICAL_OFFSETS) { + PositionKey sameColumnPosition = PositionKey.of(center.x(), center.y() + yOffset, center.z()); + + if (previousLanePositions.contains(sameColumnPosition)) + return true; + } + + return false; + } + + private record DiagonalStep(int x, int z, int y, boolean positiveSlope) { + } + + private record LaneGeometry(Set centers, Set diagonals) { + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java similarity index 57% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java index f653b082..ef33ad8a 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPreparationProgress.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailPreparationProgress.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.alpsbte.alpslib.utils.ChatHelper; import net.buildtheearth.buildteamtools.BuildTeamTools; @@ -6,18 +6,21 @@ import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + final class RailPreparationProgress implements Runnable { private final Player player; private final long maxPercentage; private final long updateIntervalTicks; - private volatile BukkitTask task; - private volatile long stageStartPercentage; - private volatile long stageEndPercentage; - private volatile long stageStartedAtMillis = System.currentTimeMillis(); - private volatile long stageEstimatedDurationMillis = 1L; - private volatile long queuedPercentage = -1L; - private volatile long lastSentPercentage = -1L; + private final AtomicReference task = new AtomicReference<>(); + private final AtomicLong stageStartPercentage = new AtomicLong(); + private final AtomicLong stageEndPercentage = new AtomicLong(); + private final AtomicLong stageStartedAtMillis = new AtomicLong(System.currentTimeMillis()); + private final AtomicLong stageEstimatedDurationMillis = new AtomicLong(1L); + private final AtomicLong queuedPercentage = new AtomicLong(-1L); + private final AtomicLong lastSentPercentage = new AtomicLong(-1L); RailPreparationProgress(Player player, long maxPercentage, long updateIntervalTicks) { this.player = player; @@ -29,36 +32,39 @@ void start() { if (!canContinue()) return; - if (task != null) + if (task.get() != null) return; - task = Bukkit.getScheduler().runTaskTimerAsynchronously( + BukkitTask newTask = Bukkit.getScheduler().runTaskTimerAsynchronously( BuildTeamTools.getInstance(), this, 0L, updateIntervalTicks ); + + if (!task.compareAndSet(null, newTask)) + newTask.cancel(); } void stop() { - BukkitTask currentTask = task; + BukkitTask currentTask = task.getAndSet(null); if (currentTask == null) return; currentTask.cancel(); - task = null; } void startStage(long startPercentage, long endPercentage, long estimatedDurationMillis) { if (!canContinue()) return; - stageStartPercentage = clamp(startPercentage); - stageEndPercentage = clamp(endPercentage); - stageStartedAtMillis = System.currentTimeMillis(); - stageEstimatedDurationMillis = Math.max(1L, estimatedDurationMillis); - update(stageStartPercentage); + long clampedStartPercentage = clamp(startPercentage); + stageStartPercentage.set(clampedStartPercentage); + stageEndPercentage.set(clamp(endPercentage)); + stageStartedAtMillis.set(System.currentTimeMillis()); + stageEstimatedDurationMillis.set(Math.max(1L, estimatedDurationMillis)); + update(clampedStartPercentage); } void completeStage(long percentage) { @@ -71,14 +77,14 @@ void update(long percentage) { long clampedPercentage = clamp(percentage); - if (clampedPercentage <= queuedPercentage) + if (clampedPercentage <= queuedPercentage.get()) return; - queuedPercentage = clampedPercentage; - if (clampedPercentage <= lastSentPercentage) + queuedPercentage.set(clampedPercentage); + if (clampedPercentage <= lastSentPercentage.get()) return; - lastSentPercentage = clampedPercentage; + lastSentPercentage.set(clampedPercentage); player.sendActionBar(ChatHelper.getStandardComponent(false, "Generator Progress: %s", clampedPercentage + "%")); } @@ -86,7 +92,7 @@ long scale(int completed, int total, long startPercentage, long endPercentage) { if (total <= 0) return endPercentage; - double progress = Math.max(0D, Math.min(1D, (double) completed / (double) total)); + double progress = Math.clamp((double) completed / (double) total, 0D, 1D); return startPercentage + Math.round(progress * (endPercentage - startPercentage)); } @@ -97,14 +103,14 @@ public void run() { return; } - long currentStageStart = stageStartPercentage; - long currentStageEnd = stageEndPercentage; + long currentStageStart = stageStartPercentage.get(); + long currentStageEnd = stageEndPercentage.get(); if (currentStageEnd <= currentStageStart) return; - long elapsedMillis = Math.max(0L, System.currentTimeMillis() - stageStartedAtMillis); - double progress = Math.min(0.98D, (double) elapsedMillis / (double) stageEstimatedDurationMillis); + long elapsedMillis = Math.max(0L, System.currentTimeMillis() - stageStartedAtMillis.get()); + double progress = Math.clamp((double) elapsedMillis / (double) stageEstimatedDurationMillis.get(), 0D, 0.98D); long estimatedPercentage = currentStageStart + (long) Math.floor(progress * (currentStageEnd - currentStageStart)); if (estimatedPercentage >= currentStageEnd) @@ -114,7 +120,7 @@ public void run() { } private long clamp(long percentage) { - return Math.max(0L, Math.min(maxPercentage, percentage)); + return Math.clamp(percentage, 0L, maxPercentage); } private boolean canContinue() { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java similarity index 72% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java index 1d055b74..210b7eb4 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailScripts.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java @@ -1,12 +1,19 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.GeneratorUtils; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.regions.Region; import com.sk89q.worldedit.world.block.BlockState; import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.Script; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import org.bukkit.Bukkit; import org.bukkit.block.Block; import org.bukkit.entity.Player; @@ -20,7 +27,7 @@ public class RailScripts extends Script { private static final int SELECTION_PADDING = 4; - private static final int SELECTION_VERTICAL_PADDING = 12; + private static final int SELECTION_VERTICAL_PADDING = RailType.MAX_OVERHEAD_POLE_HEIGHT + 2; private static final int PREPARE_SELECTION_EXPANSION = 8; private static final long BLOCK_PLACEMENT_START_PERCENTAGE = 95L; @@ -42,23 +49,27 @@ public class RailScripts extends Script { private static final long RAIL_BLOCK_BUILD_ESTIMATED_MILLIS = 1_800L; private static final long QUEUE_OPERATIONS_ESTIMATED_MILLIS = 300L; - private static final int DEFAULT_RAIL_LANE_COUNT = 1; - private static final int DEFAULT_RAIL_LANE_SPACING = 5; - private Block[][][] blocks; private List controlPoints = new ArrayList<>(); private List centerPath = new ArrayList<>(); private RailTerrainResolver terrainResolver; - private RailType railType = RailType.STANDARD; + private RailType railType = RailType.getDefault(); + private int trackCount = RailType.DEFAULT_TRACK_COUNT; + private List trackSpacings = List.of(RailType.DEFAULT_TRACK_SPACING); private final RailLimits limits; private final RailPreparationProgress preparationProgress; private final Runnable preparationFinishedCallback; private int railReferenceY; + private Region generationRegion; public RailScripts(Player player, GeneratorComponent generatorComponent, Runnable preparationFinishedCallback) { super(player, generatorComponent); this.limits = RailLimits.fromConfig(); - this.preparationProgress = new RailPreparationProgress(player, BLOCK_PLACEMENT_START_PERCENTAGE, PROGRESS_UPDATE_INTERVAL_TICKS); + this.preparationProgress = new RailPreparationProgress( + player, + BLOCK_PLACEMENT_START_PERCENTAGE, + PROGRESS_UPDATE_INTERVAL_TICKS + ); this.preparationFinishedCallback = preparationFinishedCallback; preparationProgress.start(); @@ -90,6 +101,10 @@ public RailScripts(Player player, GeneratorComponent generatorComponent, Runnabl private boolean prepareSession() { if (!canContinue()) return false; + railType = getRailType(); + + if (!resolveTrackLayout()) return false; + controlPoints = getControlPoints(); railReferenceY = getRailReferenceY(controlPoints); preparationProgress.completeStage(CONTROL_POINTS_PROGRESS); @@ -121,6 +136,7 @@ private boolean prepareSession() { selectionMinY, selectionMaxY ); + generationRegion = GeneratorUtils.getWorldEditSelection(getPlayer()); blocks = GeneratorUtils.prepareScriptSession( localSession, @@ -141,7 +157,6 @@ private boolean prepareSession() { snapMissingControlPointHeightsToTerrain(controlPoints); centerPath = createCenterPath(controlPoints); adjustCenterPathToTerrain(); - railType = getRailType(); preparationProgress.completeStage(TERRAIN_ADJUST_PROGRESS); return true; @@ -154,9 +169,34 @@ private boolean queueRailGeneration() { return false; preparationProgress.startStage(TERRAIN_ADJUST_PROGRESS, RAIL_BLOCK_BUILD_PROGRESS, RAIL_BLOCK_BUILD_ESTIMATED_MILLIS); - Map railBlocks = buildRailBlocks(centerPath); + List> railCenterPaths = new RailLanePathBuilder(controlPoints, terrainResolver, trackCount, trackSpacings) + .createRailCenterPaths(centerPath); + + if (railCenterPaths.size() < trackCount) { + sendRailError( + "Rail Generator could not create %s parallel tracks with spacing %s along this path. " + + "Reduce the track count or spacings, or use a less sharp curve.", + trackCount, + trackSpacings + ); + return false; + } + + if (new RailPathOverlapValidator().hasOverlap(railCenterPaths)) { + sendRailError( + "The parallel tracks overlap in this curve. Reduce the track count or spacing, " + + "or make the selected path less sharp." + ); + return false; + } + + Map railBlocks = buildRailBlocks(railCenterPaths); + new RailOverheadBuilder(terrainResolver, railType).addTo(railBlocks, railCenterPaths); preparationProgress.completeStage(RAIL_BLOCK_BUILD_PROGRESS); + if (!isInsideGenerationRegion(railBlocks)) + return false; + if (railBlocks.size() > limits.maxBlockPlacements()) { sendRailError( "Rail Generator would place %s blocks. The limit is %s. Split the rail into smaller selections.", @@ -183,10 +223,25 @@ private boolean queueRailGeneration() { return true; } + private boolean isInsideGenerationRegion(Map railBlocks) { + if (generationRegion != null && railBlocks.keySet().stream().allMatch(position -> generationRegion.contains( + BlockVector3.at(position.x(), position.y(), position.z()) + ))) + return true; + + sendRailError( + "The generated railway does not fit inside its safe generation area. " + + "Reduce the track count or spacing, disable overhead poles, or use a less sharp curve." + ); + return false; + } + private void queueRailBlockPlacements(Map railBlocks) { List positions = new ArrayList<>(limits.blockPlacementBatchSize()); List blockStates = new ArrayList<>(limits.blockPlacementBatchSize()); + createCommand("//perf neighbors on"); + for (Map.Entry entry : railBlocks.entrySet()) { positions.add(entry.getKey().toVector()); blockStates.add(entry.getValue()); @@ -256,7 +311,7 @@ private boolean hasValidCenterPath() { } private boolean hasSafeEstimatedBlockCount(List path) { - long estimatedBlocks = (long) path.size() * getRailLaneCount() * 5L; + long estimatedBlocks = (long) path.size() * trackCount * 5L; if (estimatedBlocks <= limits.maxBlockPlacements()) return true; @@ -358,33 +413,74 @@ private List createRailSelectionPoints(List points) { } private int getSelectionPadding() { - int sideLaneCount = (getRailLaneCount() - 1) / 2; - return SELECTION_PADDING + (getRailLaneSpacing() * sideLaneCount) + 2; + int totalTrackSpan = trackSpacings.stream().mapToInt(Integer::intValue).sum(); + int maxLaneOffset = (int) Math.ceil(totalTrackSpan / 2.0D); + int overheadPoleOffset = railType.hasOverheadPoles() ? railType.getOverheadPoleOffset() : 0; + return SELECTION_PADDING + maxLaneOffset + overheadPoleOffset + 2; } private List createCenterPath(List points) { return GeneratorUtils.removeOrthogonalCorners(GeneratorUtils.createShortestBlockPath(points)); } - private Map buildRailBlocks(List path) { + private Map buildRailBlocks(List> railCenterPaths) { return new RailBlockBuilder( - controlPoints, terrainResolver, railType, preparationProgress, - getRailLaneCount(), - getRailLaneSpacing(), TERRAIN_ADJUST_PROGRESS, RAIL_BLOCK_BUILD_PROGRESS - ).build(path); + ).build(railCenterPaths); } - private int getRailLaneCount() { - return DEFAULT_RAIL_LANE_COUNT; + private boolean resolveTrackLayout() { + trackCount = railType.getTrackCount(); + int trackSpacing = railType.getTrackSpacing(); + trackSpacings = railType.getTrackSpacings(); + + Integer trackCountFlag = getIntegerSetting(RailFlag.TRACK_COUNT); + Integer trackSpacingFlag = getIntegerSetting(RailFlag.TRACK_SPACING); + + if (trackCountFlag != null) + trackCount = trackCountFlag; + + if (trackSpacingFlag != null) { + trackSpacing = trackSpacingFlag; + trackSpacings = Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + } else if (trackCount != railType.getTrackCount()) { + trackSpacings = Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); + } + + if (trackCount < RailType.MIN_TRACK_COUNT || trackCount > RailType.MAX_TRACK_COUNT) { + sendRailError("Track count must be between %s and %s.", RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT); + return false; + } + + if (trackSpacing < RailType.MIN_TRACK_SPACING || trackSpacing > RailType.MAX_TRACK_SPACING) { + sendRailError("Track spacing must be between %s and %s.", RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING); + return false; + } + + if (trackSpacings.size() != Math.max(0, trackCount - 1) + || trackSpacings.stream().anyMatch(spacing -> spacing < RailType.MIN_TRACK_SPACING + || spacing > RailType.MAX_TRACK_SPACING)) { + sendRailError("Every track spacing must be between %s and %s.", + RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING); + return false; + } + + return trackCount == RailType.MIN_TRACK_COUNT + || RailPermissionGuard.check(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); } - private int getRailLaneSpacing() { - return DEFAULT_RAIL_LANE_SPACING; + private Integer getIntegerSetting(RailFlag flag) { + Settings settings = getGeneratorComponent().getPlayerSettings().get(getPlayer().getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return null; + + Object value = railSettings.getValues().get(flag); + return value instanceof Integer intValue ? intValue : null; } private void snapMissingControlPointHeightsToTerrain(List points) { @@ -400,7 +496,12 @@ private void adjustCenterPathToTerrain() { for (int index = 0; index < centerPath.size(); index++) { Vector point = centerPath.get(index); point.setY(terrainResolver.getNearestRailSurfaceY(point.getBlockX(), point.getBlockZ(), point.getBlockY())); - preparationProgress.update(preparationProgress.scale(index + 1, centerPath.size(), TERRAIN_PREPARE_PROGRESS, TERRAIN_ADJUST_PROGRESS)); + preparationProgress.update(preparationProgress.scale( + index + 1, + centerPath.size(), + TERRAIN_PREPARE_PROGRESS, + TERRAIN_ADJUST_PROGRESS + )); } } @@ -429,10 +530,10 @@ private RailType getRailType() { Settings settings = getGeneratorComponent().getPlayerSettings().get(getPlayer().getUniqueId()); if (!(settings instanceof RailSettings railSettings)) - return RailType.STANDARD; + return RailType.getDefault(); Object value = railSettings.getValues().get(RailFlag.RAIL_TYPE); - return value instanceof RailType selectedRailType ? selectedRailType : RailType.STANDARD; + return value instanceof RailType selectedRailType ? selectedRailType : RailType.getDefault(); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java similarity index 97% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java index 67b91f2c..b6c6cc90 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSideBlock.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSideBlock.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.sk89q.worldedit.util.Direction; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java similarity index 88% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java index 20c40538..31fb5486 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailSidePlacement.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailSidePlacement.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import com.sk89q.worldedit.util.Direction; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java similarity index 51% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java index 0de43935..e6cc6472 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailStep.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailStep.java @@ -1,4 +1,8 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; record RailStep(int dx, int dz) { + + RailStep opposite() { + return new RailStep(-dx, -dz); + } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java similarity index 99% rename from src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java rename to src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java index 2ae5ff29..4b55ff9d 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailTerrainResolver.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailTerrainResolver.java @@ -1,4 +1,4 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; import net.buildtheearth.buildteamtools.utils.MenuItems; import org.bukkit.Material; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java new file mode 100644 index 00000000..8d9fbcbe --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java @@ -0,0 +1,86 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.buildtheearth.buildteamtools.utils.menus.BlockListMenu; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.List; + +/** + * Lets the player pick a single block for one of the {@link RailBlockRole} slots + * of a rail type draft, then returns to the editor menu. + */ +public class RailBlockPickerMenu extends BlockListMenu { + + private final RailTypeDraft draft; + private final RailBlockRole role; + + RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, boolean autoLoad) { + super(player, role.getMenuTitle(), role.createChoices(), createParentMenu(player, draft, role), autoLoad); + + this.draft = draft; + this.role = role; + } + + @Override + protected void setPaginatedItemClickEventsAsync(List source) { + List itemStacks = source.stream().map(l -> (ItemStack) l).toList(); + int slot = 0; + + // Only one block can be picked per role, so selecting a block deselects the previous one. + for (ItemStack ignored : itemStacks) { + final int _slot = slot; + getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { + String type = Item.getUppercaseMaterialString(getMenu().getSlot(_slot).getItem(getMenuPlayer())); + + if (selectedMaterials.contains(type)) { + selectedMaterials.remove(type); + } else { + selectedMaterials.clear(); + selectedMaterials.add(type); + } + + reloadMenuAsync(); + }); + slot++; + } + } + + @Override + protected void setItemClickEventsAsync() { + super.setItemClickEventsAsync(); + + if (canProceed()) + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + XMaterial material = Item.convertStringToXMaterial(selectedMaterials.getFirst()); + + if (material == null) + return; + + role.apply(draft, material); + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + if (role.isOverheadSetting()) + new RailOverheadWireMenu(clickPlayer, draft, true); + else + new RailTypeEditorMenu(clickPlayer, draft, true); + }); + } + + private static AbstractMenu createParentMenu(Player player, RailTypeDraft draft, RailBlockRole role) { + if (role.isOverheadSetting()) + return new RailOverheadWireMenu(player, draft, false); + + return new RailTypeEditorMenu(player, draft, false); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java new file mode 100644 index 00000000..3903de5f --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java @@ -0,0 +1,130 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import org.bukkit.inventory.ItemStack; + +import java.util.List; + +/** + * The block slots of a rail type that can be configured in the editor menu. + * Each role provides its own predefined list of valid blocks to choose from. + */ +enum RailBlockRole { + + RAIL_BLOCK("Choose a Rail Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setRailBlock(material); + } + }, + + BLOCK_BELOW("Choose a Block Below the Rails") { + @Override + List createChoices() { + return MenuItems.getSolidBlocks(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setBlockBelow(material); + } + }, + + SLEEPER_BLOCK("Choose a Sleeper Block") { + @Override + List createChoices() { + return MenuItems.getSolidBlocks(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setSleeperBlock(material); + } + }, + + ICON("Choose a Rail Type Icon") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setIcon(material); + } + }, + + OVERHEAD_POLE_BLOCK("Choose an Overhead Pole Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadPoleBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }, + + OVERHEAD_SUPPORT_BLOCK("Choose a Top Support Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadSupportBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }, + + OVERHEAD_WIRE_BLOCK("Choose an Overhead Wire Block") { + @Override + List createChoices() { + return MenuItems.getBlocksByColor(); + } + + @Override + void apply(RailTypeDraft draft, XMaterial material) { + draft.setOverheadWireBlock(material); + } + + @Override + boolean isOverheadSetting() { + return true; + } + }; + + @Getter + private final String menuTitle; + + RailBlockRole(String menuTitle) { + this.menuTitle = menuTitle; + } + + abstract List createChoices(); + + abstract void apply(RailTypeDraft draft, XMaterial material); + + boolean isOverheadSetting() { + return false; + } + +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java new file mode 100644 index 00000000..1853c088 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailMenuText.java @@ -0,0 +1,16 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import lombok.experimental.UtilityClass; +import net.kyori.adventure.text.format.NamedTextColor; + +@UtilityClass +final class RailMenuText { + + static final String ENABLED = "Enabled"; + static final String DISABLED = "Disabled"; + + static String color(NamedTextColor color, String text) { + return ChatHelper.getColorizedString(color, text, false); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java new file mode 100644 index 00000000..9786f650 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java @@ -0,0 +1,318 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +import java.util.List; +import java.util.Objects; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; + +final class RailOverheadWireMenu extends AbstractMenu { + + private static final String MENU_TITLE = "Overhead Wire Settings"; + private static final String FILLED_MASK_ROW = "111111111"; + private static final String BLOCKS_UNIT = "Blocks"; + + private static final int POLES_TOGGLE_SLOT = 10; + private static final int POLE_BLOCK_SLOT = 16; + private static final int SUPPORT_BLOCK_SLOT = 22; + private static final int WIRES_TOGGLE_SLOT = 19; + private static final int WIRE_BLOCK_SLOT = 25; + private static final int POLE_SPACING_SLOT = 28; + private static final int POLE_OFFSET_SLOT = 31; + private static final int POLE_HEIGHT_SLOT = 34; + private static final int BACK_ITEM_SLOT = 36; + private static final int DONE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailOverheadWireMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + super(5, MENU_TITLE, player, false); + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + getMenu().getSlot(POLES_TOGGLE_SLOT).setItem(createToggleItem( + "Overhead Poles", + draft.isOverheadPolesEnabled(), + "Generate poles alongside the tracks." + )); + getMenu().getSlot(WIRES_TOGGLE_SLOT).setItem(createToggleItem( + "Overhead Wires", + draft.isOverheadWiresEnabled(), + "Generate wires above the tracks." + )); + getMenu().getSlot(POLE_BLOCK_SLOT).setItem(createBlockItem( + "Pole Block", + draft.getOverheadPoleBlock() + )); + getMenu().getSlot(SUPPORT_BLOCK_SLOT).setItem(createBlockItem( + "Top Support Block", + draft.getOverheadSupportBlock() + )); + getMenu().getSlot(WIRE_BLOCK_SLOT).setItem(createBlockItem( + "Wire Block", + draft.getOverheadWireBlock() + )); + + getMenu().getSlot(POLE_SPACING_SLOT).setItem(createSpacingItem()); + createCounter( + HeadColor.LIGHT_GRAY, + POLE_OFFSET_SLOT, + "Pole Offset", + draft.getOverheadPoleOffset(), + RailType.MIN_OVERHEAD_POLE_OFFSET, + RailType.MAX_OVERHEAD_POLE_OFFSET, + BLOCKS_UNIT + ); + createCounter( + HeadColor.WHITE, + POLE_HEIGHT_SLOT, + "Pole Height", + draft.getOverheadPoleHeight(), + RailType.MIN_OVERHEAD_POLE_HEIGHT, + RailType.MAX_OVERHEAD_POLE_HEIGHT, + BLOCKS_UNIT + ); + + setBackItem(BACK_ITEM_SLOT, new RailTypeEditorMenu(getMenuPlayer(), draft, false)); + getMenu().getSlot(DONE_ITEM_SLOT).setItem(HeadFactory.head( + HeadTexture.CHECKMARK, + green("Done") + )); + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All items use local draft state. + } + + @Override + protected void setItemClickEventsAsync() { + getMenu().getSlot(POLES_TOGGLE_SLOT).setClickHandler((player, click) -> { + draft.setOverheadPolesEnabled(!draft.isOverheadPolesEnabled()); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + getMenu().getSlot(WIRES_TOGGLE_SLOT).setClickHandler((player, click) -> { + draft.setOverheadWiresEnabled(!draft.isOverheadWiresEnabled()); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + + setBlockPickerClickEvent(POLE_BLOCK_SLOT, RailBlockRole.OVERHEAD_POLE_BLOCK); + setBlockPickerClickEvent(SUPPORT_BLOCK_SLOT, RailBlockRole.OVERHEAD_SUPPORT_BLOCK); + setBlockPickerClickEvent(WIRE_BLOCK_SLOT, RailBlockRole.OVERHEAD_WIRE_BLOCK); + getMenu().getSlot(POLE_SPACING_SLOT).setClickHandler((player, click) -> openSpacingEditor(player)); + setCounterClickEvents( + POLE_OFFSET_SLOT, + RailType.MIN_OVERHEAD_POLE_OFFSET, + RailType.MAX_OVERHEAD_POLE_OFFSET, + draft::getOverheadPoleOffset, + draft::setOverheadPoleOffset + ); + setCounterClickEvents( + POLE_HEIGHT_SLOT, + RailType.MIN_OVERHEAD_POLE_HEIGHT, + RailType.MAX_OVERHEAD_POLE_HEIGHT, + draft::getOverheadPoleHeight, + draft::setOverheadPoleHeight + ); + + getMenu().getSlot(DONE_ITEM_SLOT).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailTypeEditorMenu(player, draft, true); + }); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void setBlockPickerClickEvent(int slot, RailBlockRole role) { + getMenu().getSlot(slot).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailBlockPickerMenu(player, draft, role, true); + }); + } + + private void setCounterClickEvents( + int slot, + int minimum, + int maximum, + IntSupplier getter, + IntConsumer setter + ) { + getMenu().getSlot(slot - 1).setClickHandler((player, click) -> + changeCounter(player, minimum, getter.getAsInt() - 1, getter, setter)); + getMenu().getSlot(slot + 1).setClickHandler((player, click) -> + changeCounter(player, maximum, getter.getAsInt() + 1, getter, setter)); + } + + private void changeCounter( + Player player, + int boundary, + int newValue, + IntSupplier getter, + IntConsumer setter + ) { + if (getter.getAsInt() == boundary) { + playSound(player, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(newValue); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + } + + private ItemStack createToggleItem(String name, boolean enabled, String description) { + return Item.create( + Objects.requireNonNull(XMaterial.LEVER.get()), + yellow(name + ": ") + (enabled + ? green(RailMenuText.ENABLED) + : red(RailMenuText.DISABLED)), + List.of(gray(description), gray("Click to toggle.")) + ); + } + + private ItemStack createBlockItem(String name, XMaterial material) { + Material bukkitMaterial = material.get(); + + if (bukkitMaterial == null) + bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); + + return Item.create( + bukkitMaterial, + yellow(name + ": ") + white(RailTypeMenu.formatMaterial(material)), + List.of(gray("Click to choose a different block.")) + ); + } + + private ItemStack createSpacingItem() { + return Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Pole Spacing: ") + white(draft.getOverheadPoleSpacing() + " " + BLOCKS_UNIT), + List.of( + gray("Click to enter a custom spacing."), + gray("Valid range: ") + white(RailType.MIN_OVERHEAD_POLE_SPACING + + "-" + RailType.MAX_OVERHEAD_POLE_SPACING + " " + BLOCKS_UNIT) + ) + ); + } + + private void openSpacingEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String input = stateSnapshot.getText().trim(); + Integer spacing = parseSpacing(input); + + if (spacing == null) { + stateSnapshot.getPlayer().sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Pole spacing must be a number between %s and %s.", + RailType.MIN_OVERHEAD_POLE_SPACING, + RailType.MAX_OVERHEAD_POLE_SPACING + ))); + return List.of(AnvilGUI.ResponseAction.replaceInputText(String.valueOf(draft.getOverheadPoleSpacing()))); + } + + draft.setOverheadPoleSpacing(spacing); + playSound(stateSnapshot.getPlayer(), Sound.UI_BUTTON_CLICK); + stateSnapshot.getPlayer().sendMessage(ChatHelper.getStandardComponent( + true, + "Pole spacing set to %s blocks.", + spacing + )); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailOverheadWireMenu(clickPlayer, draft, true)) + ); + }) + .text(String.valueOf(draft.getOverheadPoleSpacing())) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Change Pole Spacing"))) + .title(darkGray("Change pole spacing")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private Integer parseSpacing(String input) { + try { + int spacing = Integer.parseInt(input); + + if (spacing < RailType.MIN_OVERHEAD_POLE_SPACING || spacing > RailType.MAX_OVERHEAD_POLE_SPACING) + return null; + + return spacing; + } catch (NumberFormatException exception) { + return null; + } + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java new file mode 100644 index 00000000..d8228439 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTrackSpacingMenu.java @@ -0,0 +1,105 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +/** Configures the distance for each gap between up to eight parallel tracks. */ +final class RailTrackSpacingMenu extends AbstractMenu { + + private static final int[] GAP_SLOTS = {2, 6, 11, 15, 20, 24, 29}; + private static final String FILLED_MASK_ROW = "111111111"; + private static final int BACK_ITEM_SLOT = 36; + private static final int DONE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailTrackSpacingMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + super(5, "Track Spacing Settings", player, false); + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + for (int gapIndex = 0; gapIndex < draft.getTrackSpacings().size(); gapIndex++) { + createCounter( + gapIndex % 2 == 0 ? HeadColor.WHITE : HeadColor.LIGHT_GRAY, + GAP_SLOTS[gapIndex], + "Tracks " + (gapIndex + 1) + " & " + (gapIndex + 2), + draft.getTrackSpacings().get(gapIndex), + RailType.MIN_TRACK_SPACING, + RailType.MAX_TRACK_SPACING, + "Blocks" + ); + } + + setBackItem(BACK_ITEM_SLOT, new RailTypeEditorMenu(getMenuPlayer(), draft, false)); + getMenu().getSlot(DONE_ITEM_SLOT).setItem(HeadFactory.head(HeadTexture.CHECKMARK, "§aDone")); + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All values live in the local draft. + } + + @Override + protected void setItemClickEventsAsync() { + for (int gapIndex = 0; gapIndex < draft.getTrackSpacings().size(); gapIndex++) { + int selectedGap = gapIndex; + int slot = GAP_SLOTS[gapIndex]; + + getMenu().getSlot(slot - 1).setClickHandler((player, click) -> changeSpacing(player, selectedGap, -1)); + getMenu().getSlot(slot + 1).setClickHandler((player, click) -> changeSpacing(player, selectedGap, 1)); + } + + getMenu().getSlot(DONE_ITEM_SLOT).setClickHandler((player, click) -> { + player.closeInventory(); + playSound(player, Sound.UI_BUTTON_CLICK); + new RailTypeEditorMenu(player, draft, true); + }); + } + + private void changeSpacing(Player player, int gapIndex, int change) { + int current = draft.getTrackSpacings().get(gapIndex); + int updated = current + change; + + if (updated < RailType.MIN_TRACK_SPACING || updated > RailType.MAX_TRACK_SPACING) { + playSound(player, Sound.ENTITY_ITEM_BREAK); + return; + } + + draft.setTrackSpacing(gapIndex, updated); + playSound(player, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java new file mode 100644 index 00000000..b79f610d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java @@ -0,0 +1,100 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import lombok.Getter; +import lombok.Setter; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; + +import java.util.List; + +/** + * Mutable state of a rail type that is being created in the editor menu. + */ +@Getter +@Setter +class RailTypeDraft { + + private String identifier; + private String displayName; + private XMaterial icon = XMaterial.RAIL; + private XMaterial railBlock = XMaterial.ANVIL; + private XMaterial blockBelow = XMaterial.GRAVEL; + private XMaterial sleeperBlock = XMaterial.SPRUCE_PLANKS; + private int sleeperSpacing = RailType.MIN_SLEEPER_SPACING; + private int trackCount = RailType.DEFAULT_TRACK_COUNT; + private int trackSpacing = RailType.DEFAULT_TRACK_SPACING; + private List trackSpacings = new java.util.ArrayList<>(List.of(RailType.DEFAULT_TRACK_SPACING)); + private boolean overheadPolesEnabled = true; + private XMaterial overheadPoleBlock = XMaterial.LIGHT_GRAY_CONCRETE; + private XMaterial overheadSupportBlock = XMaterial.LIGHT_GRAY_CONCRETE; + private int overheadPoleSpacing = RailType.DEFAULT_OVERHEAD_POLE_SPACING; + private int overheadPoleOffset = RailType.DEFAULT_OVERHEAD_POLE_OFFSET; + private int overheadPoleHeight = RailType.DEFAULT_OVERHEAD_POLE_HEIGHT; + private boolean overheadWiresEnabled = true; + private XMaterial overheadWireBlock = XMaterial.IRON_BARS; + private boolean trackSwitchesEnabled; + + static RailTypeDraft from(RailType railType, boolean keepIdentity) { + RailTypeDraft draft = new RailTypeDraft(); + List blocksBelow = railType.getBlocksBelow(); + + if (keepIdentity) { + draft.identifier = railType.getIdentifier(); + draft.displayName = railType.getDisplayName(); + } + + draft.icon = railType.getIcon(); + draft.railBlock = railType.getRailBlock(); + draft.blockBelow = blocksBelow.isEmpty() ? XMaterial.GRAVEL : blocksBelow.getFirst(); + draft.sleeperBlock = railType.getSleeperBlock() == null ? XMaterial.SPRUCE_PLANKS : railType.getSleeperBlock(); + draft.sleeperSpacing = railType.getSleeperSpacing(); + draft.trackCount = railType.getTrackCount(); + draft.trackSpacing = railType.getTrackSpacing(); + draft.trackSpacings = new java.util.ArrayList<>(railType.getTrackSpacings()); + draft.overheadPolesEnabled = railType.isOverheadPolesEnabled(); + draft.overheadPoleBlock = railType.getOverheadPoleBlock() == null + ? XMaterial.LIGHT_GRAY_CONCRETE + : railType.getOverheadPoleBlock(); + draft.overheadSupportBlock = railType.getOverheadSupportBlock() == null + ? XMaterial.LIGHT_GRAY_CONCRETE + : railType.getOverheadSupportBlock(); + draft.overheadPoleSpacing = railType.getOverheadPoleSpacing(); + draft.overheadPoleOffset = railType.getOverheadPoleOffset(); + draft.overheadPoleHeight = railType.getOverheadPoleHeight(); + draft.overheadWiresEnabled = railType.isOverheadWiresEnabled(); + draft.overheadWireBlock = railType.getOverheadWireBlock() == null + ? XMaterial.IRON_BARS + : railType.getOverheadWireBlock(); + draft.trackSwitchesEnabled = railType.isTrackSwitchesEnabled(); + return draft; + } + + void setTrackCount(int trackCount) { + this.trackCount = trackCount; + resizeTrackSpacings(); + } + + void setTrackSpacing(int trackSpacing) { + this.trackSpacing = trackSpacing; + this.trackSpacings = new java.util.ArrayList<>(java.util.Collections.nCopies( + Math.max(0, trackCount - 1), + trackSpacing + )); + } + + void setTrackSpacing(int gapIndex, int spacing) { + resizeTrackSpacings(); + trackSpacings.set(gapIndex, spacing); + trackSpacing = trackSpacings.getFirst(); + } + + private void resizeTrackSpacings() { + int requiredSize = Math.max(0, trackCount - 1); + + while (trackSpacings.size() < requiredSize) + trackSpacings.add(trackSpacing); + + while (trackSpacings.size() > requiredSize) + trackSpacings.removeLast(); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java new file mode 100644 index 00000000..896b16a9 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java @@ -0,0 +1,382 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.ChatHelper; +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; +import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; +import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; +import net.buildtheearth.buildteamtools.utils.MenuItems; +import net.buildtheearth.buildteamtools.utils.heads.HeadColor; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; +import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.ipvp.canvas.mask.BinaryMask; +import org.ipvp.canvas.mask.Mask; + +import java.util.List; +import java.util.Objects; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; + +/** + * Editor for creating a custom rail type in-game. The configured draft is + * validated and saved persistently through the {@link RailTypeManager}. + */ +public class RailTypeEditorMenu extends AbstractMenu { + + public static final String EDITOR_INV_NAME = "Create a Rail Type"; + private static final String FILLED_MASK_ROW = "111111111"; + + private static final int NAME_SLOT = 14; + + private static final int TRACK_COUNT_SLOT = 2; + private static final int TRACK_SPACING_SLOT = 11; + private static final int SLEEPER_SPACING_SLOT = 20; + + private static final int RAIL_BLOCK_SLOT = 7; + private static final int BLOCK_BELOW_SLOT = 16; + private static final int SLEEPER_BLOCK_SLOT = 25; + private static final int ICON_SLOT = 23; + private static final int OVERHEAD_SETTINGS_SLOT = 32; + private static final int TRACK_SWITCHES_SLOT = 30; + + private static final int BACK_ITEM_SLOT = 36; + private static final int SAVE_ITEM_SLOT = 44; + + private final RailTypeDraft draft; + + RailTypeEditorMenu(Player player, RailTypeDraft draft, boolean autoLoad) { + // The parent constructor renders immediately, so load manually after the draft is set. + super(5, EDITOR_INV_NAME, player, false); + + this.draft = draft; + + if (autoLoad) + reloadMenuAsync(); + } + + @Override + protected void setPreviewItems() { + if (getMask() != null) + getMask().apply(getMenu()); + + getMenu().getSlot(NAME_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Rail Type Name: ") + white(getDisplayName()), + List.of(gray("Click to change the display name.")) + )); + + createCounter(HeadColor.WHITE, TRACK_COUNT_SLOT, "Track Count", draft.getTrackCount(), + RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, "Tracks"); + createCounter(HeadColor.LIGHT_GRAY, TRACK_SPACING_SLOT, "Track Spacing", draft.getTrackSpacing(), + RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING, "Blocks"); + getMenu().getSlot(TRACK_SPACING_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NAME_TAG.get()), + yellow("Track Spacings"), + List.of( + gray("Between tracks: ") + white(draft.getTrackSpacings().toString()), + gray("Use +/- to set all spacings."), + gray("Click to configure each gap separately.") + ) + )); + createCounter(HeadColor.WHITE, SLEEPER_SPACING_SLOT, "Sleeper Spacing", draft.getSleeperSpacing(), + RailType.MIN_SLEEPER_SPACING, RailType.MAX_SLEEPER_SPACING, "Blocks"); + + getMenu().getSlot(RAIL_BLOCK_SLOT).setItem(createBlockItem( + "Rail Block", + draft.getRailBlock(), + "The block the rails are made of." + )); + getMenu().getSlot(BLOCK_BELOW_SLOT).setItem(createBlockItem( + "Block Below the Rails", + draft.getBlockBelow(), + "The block placed between and below the rails." + )); + getMenu().getSlot(SLEEPER_BLOCK_SLOT).setItem(createBlockItem( + "Sleeper Block", + draft.getSleeperBlock(), + "Sleeper Spacing 0 disables sleepers." + )); + getMenu().getSlot(ICON_SLOT).setItem(createBlockItem( + "Menu Icon", + draft.getIcon(), + "The icon shown in the rail type menu." + )); + getMenu().getSlot(OVERHEAD_SETTINGS_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.IRON_BARS.get()), + yellow("Overhead Settings"), + List.of( + gray("Poles: ") + white(draft.isOverheadPolesEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED), + gray("Wires: ") + white(draft.isOverheadWiresEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED), + gray("Click to configure overhead wires.") + ) + )); + getMenu().getSlot(TRACK_SWITCHES_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.LEVER.get()), + yellow("Track Switches: ") + + (draft.isTrackSwitchesEnabled() + ? green(RailMenuText.ENABLED) + : red(RailMenuText.DISABLED)), + List.of( + gray("Prepares this type for future switch generation."), + gray("Click to toggle.") + ) + )); + + setBackItem(BACK_ITEM_SLOT, new RailTypeMenu(getMenuPlayer(), false)); + getMenu().getSlot(SAVE_ITEM_SLOT).setItem(HeadFactory.head( + HeadTexture.CHECKMARK, + green(draft.getIdentifier() == null ? "Save Rail Type" : "Update Rail Type") + )); + + getMenu().open(getMenuPlayer()); + } + + @Override + protected void setMenuItemsAsync() { + // All editor items are rendered synchronously because they depend only on the local draft state. + } + + @Override + protected void setItemClickEventsAsync() { + getMenu().getSlot(NAME_SLOT).setClickHandler((clickPlayer, clickInformation) -> openNameEditor(clickPlayer)); + + setCounterClickEvents(TRACK_COUNT_SLOT, RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, + draft::getTrackCount, draft::setTrackCount); + setCounterClickEvents(TRACK_SPACING_SLOT, RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING, + draft::getTrackSpacing, draft::setTrackSpacing); + getMenu().getSlot(TRACK_SPACING_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTrackSpacingMenu(clickPlayer, draft, true); + }); + setCounterClickEvents(SLEEPER_SPACING_SLOT, RailType.MIN_SLEEPER_SPACING, RailType.MAX_SLEEPER_SPACING, + draft::getSleeperSpacing, draft::setSleeperSpacing); + + setBlockPickerClickEvents(RAIL_BLOCK_SLOT, RailBlockRole.RAIL_BLOCK); + setBlockPickerClickEvents(BLOCK_BELOW_SLOT, RailBlockRole.BLOCK_BELOW); + setBlockPickerClickEvents(SLEEPER_BLOCK_SLOT, RailBlockRole.SLEEPER_BLOCK); + setBlockPickerClickEvents(ICON_SLOT, RailBlockRole.ICON); + getMenu().getSlot(TRACK_SWITCHES_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + draft.setTrackSwitchesEnabled(!draft.isTrackSwitchesEnabled()); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + getMenu().getSlot(OVERHEAD_SETTINGS_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailOverheadWireMenu(clickPlayer, draft, true); + }); + + getMenu().getSlot(SAVE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> saveRailType(clickPlayer)); + } + + @Override + protected Mask getMask() { + return BinaryMask.builder(getMenu()) + .item(MenuItems.ITEM_BACKGROUND) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .pattern(FILLED_MASK_ROW) + .build(); + } + + private void openNameEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String displayName = stateSnapshot.getText().trim(); + + if (displayName.isEmpty()) { + stateSnapshot.getPlayer().sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Rail type name cannot be empty." + ))); + return List.of(AnvilGUI.ResponseAction.replaceInputText(getDisplayName())); + } + + draft.setDisplayName(displayName); + playSound(stateSnapshot.getPlayer(), Sound.UI_BUTTON_CLICK); + stateSnapshot.getPlayer().sendMessage(ChatHelper.getStandardComponent( + true, + "Rail type name set to '%s'.", + displayName + )); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailTypeEditorMenu(clickPlayer, draft, true)) + ); + }) + .text(getDisplayName()) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Change Rail Type Name"))) + .title(darkGray("Change rail type name")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private void saveRailType(Player clickPlayer) { + String permission = draft.getIdentifier() == null ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; + + if (!RailPermissionGuard.check(clickPlayer, permission)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailTypeManager railTypeManager = rail.getRailTypeManager(); + String identifier = draft.getIdentifier() == null ? railTypeManager.getNextCustomIdentifier() : draft.getIdentifier(); + String displayName = draft.getDisplayName() == null + ? "Custom Rail " + identifier.substring("custom-".length()) + : draft.getDisplayName(); + + RailType railType = RailType.createCustom(new RailType.Configuration() + .identifier(identifier) + .displayName(displayName) + .icon(draft.getIcon()) + .railBlock(draft.getRailBlock()) + .blocksBelow(List.of(draft.getBlockBelow())) + .sleeperBlock(draft.getSleeperBlock()) + .sleeperSpacing(draft.getSleeperSpacing()) + .trackCount(draft.getTrackCount()) + .trackSpacing(draft.getTrackSpacing()) + .trackSpacings(draft.getTrackSpacings()) + .overheadPolesEnabled(draft.isOverheadPolesEnabled()) + .overheadPoleBlock(draft.getOverheadPoleBlock()) + .overheadSupportBlock(draft.getOverheadSupportBlock()) + .overheadPoleSpacing(draft.getOverheadPoleSpacing()) + .overheadPoleOffset(draft.getOverheadPoleOffset()) + .overheadPoleHeight(draft.getOverheadPoleHeight()) + .overheadWiresEnabled(draft.isOverheadWiresEnabled()) + .overheadWireBlock(draft.getOverheadWireBlock()) + .trackSwitchesEnabled(draft.isTrackSwitchesEnabled())); + + String error = railTypeManager.saveRailType(railType); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + Settings settings = rail.getPlayerSettings().get(clickPlayer.getUniqueId()); + + if (settings instanceof RailSettings railSettings) + railSettings.setValue(RailFlag.RAIL_TYPE, railType); + + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Saved rail type %s as '%s'.", + displayName, + identifier + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailTypeMenu(clickPlayer, true); + } + + private void setBlockPickerClickEvents(int slot, RailBlockRole role) { + getMenu().getSlot(slot).setClickHandler((clickPlayer, clickInformation) -> { + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailBlockPickerMenu(clickPlayer, draft, role, true); + }); + } + + private void setCounterClickEvents(int slot, int minValue, int maxValue, IntSupplier getter, IntConsumer setter) { + getMenu().getSlot(slot - 1).setClickHandler((clickPlayer, clickInformation) -> { + if (getter.getAsInt() <= minValue) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(getter.getAsInt() - 1); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + + getMenu().getSlot(slot + 1).setClickHandler((clickPlayer, clickInformation) -> { + if (getter.getAsInt() >= maxValue) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + setter.accept(getter.getAsInt() + 1); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(); + }); + } + + private ItemStack createBlockItem(String name, XMaterial material, String description) { + Material bukkitMaterial = material.get(); + + if (bukkitMaterial == null) + bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); + + return Item.create( + bukkitMaterial, + yellow(name + ": ") + white(RailTypeMenu.formatMaterial(material)), + List.of(gray(description), gray("Click to choose a different block.")) + ); + } + + private String getDisplayName() { + if (draft.getDisplayName() != null) + return draft.getDisplayName(); + + return draft.getIdentifier() == null ? "Custom Rail" : draft.getIdentifier(); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java index 8bb5f6ad..945c88e7 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java @@ -1,61 +1,594 @@ package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; +import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.menu.GeneratorMenu; import net.buildtheearth.buildteamtools.modules.generator.model.Settings; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; +import net.buildtheearth.buildteamtools.utils.heads.HeadFactory; +import net.buildtheearth.buildteamtools.utils.heads.HeadTexture; import net.buildtheearth.buildteamtools.utils.menus.NameListMenu; +import net.wesjd.anvilgui.AnvilGUI; +import net.kyori.adventure.text.format.NamedTextColor; import org.apache.commons.lang3.tuple.MutablePair; import org.bukkit.Sound; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Objects; +import java.util.Set; public class RailTypeMenu extends NameListMenu { public static final String RAIL_TYPE_INV_NAME = "Choose a Rail Type"; + // Slots 30-32 belong to the page switcher, so the extra buttons sit next to it. + private static final int SEARCH_ITEM_SLOT = 28; + private static final int CREATE_ITEM_SLOT = 29; + private static final int RELOAD_ITEM_SLOT = 33; + private static final int BULK_DELETE_ITEM_SLOT = 34; + + private final String searchQuery; + private final Set selectedForDeletion; + public RailTypeMenu(Player player, boolean autoLoad) { - super(player, RAIL_TYPE_INV_NAME, getRailTypes(), new GeneratorMenu(player, false), autoLoad); + this(player, "", Set.of(), autoLoad); + } + + private RailTypeMenu(Player player, String searchQuery, Collection selectedForDeletion, boolean autoLoad) { + super(player, RAIL_TYPE_INV_NAME, getRailTypes(searchQuery), new GeneratorMenu(player, false), autoLoad); + this.searchQuery = searchQuery; + this.selectedForDeletion = new LinkedHashSet<>(selectedForDeletion); + preselectCurrentRailType(player); + } + + private void preselectCurrentRailType(Player player) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + Settings settings = rail.getPlayerSettings().get(player.getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return; + + Object value = railSettings.getValues().get(RailFlag.RAIL_TYPE); + + if (value instanceof RailType railType && RailType.byString(railType.getIdentifier()) != null) + selectedNames.add(railType.getIdentifier()); + else if (value instanceof String identifier && RailType.byString(identifier) != null) + selectedNames.add(identifier); } - private static @NonNull List> getRailTypes() { + private static @NonNull List> getRailTypes(String searchQuery) { List> railTypes = new ArrayList<>(); + Rail rail = GeneratorModule.getInstance().getRail(); - for (RailType railType : RailType.values()) { - railTypes.add(new MutablePair<>( - Item.create(Objects.requireNonNull(railType.getIcon().get()), railType.getDisplayName()), - railType.getIdentifier() - )); + if (rail == null) + return railTypes; + + String normalizedQuery = searchQuery.trim().toLowerCase(Locale.ROOT); + + for (RailType railType : rail.getRailTypeManager().getRailTypes()) { + if (!normalizedQuery.isEmpty() + && !railType.getIdentifier().toLowerCase(Locale.ROOT).contains(normalizedQuery) + && !railType.getDisplayName().toLowerCase(Locale.ROOT).contains(normalizedQuery)) + continue; + + railTypes.add(new MutablePair<>(RailTypeMenuItems.createRailTypeItem(railType), railType.getIdentifier())); } return railTypes; } + static String formatMaterials(List materials) { + return RailTypeMenuItems.formatMaterials(materials); + } + + static String formatMaterial(@Nullable XMaterial material) { + return RailTypeMenuItems.formatMaterial(material); + } + + @Override + protected void setPreviewItems() { + super.setPreviewItems(); + + setRailTypePageItems(); + + getMenu().getSlot(SEARCH_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.COMPASS.get()), + yellow("Search Rail Types"), + List.of( + gray("Current filter: ") + white(searchQuery.isBlank() ? "All" : searchQuery), + gray("Left-click to search."), + gray("Right-click to clear the filter.") + ) + )); + + getMenu().getSlot(CREATE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NETHER_STAR.get()), + green("Create a Rail Type"), + List.of(gray("Configure and save a custom rail type.")) + )); + + getMenu().getSlot(RELOAD_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.CLOCK.get()), + yellow("Reload Rail Types"), + List.of( + gray("Re-reads rail-types.yml from disk,"), + gray("so you can test changes without a restart.") + ) + )); + + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull((selectedForDeletion.isEmpty() ? XMaterial.GRAY_DYE : XMaterial.RED_DYE).get()), + selectedForDeletion.isEmpty() ? gray("Bulk Delete") : red("Delete Selected Rail Types"), + List.of( + gray("Selected: ") + white(String.valueOf(selectedForDeletion.size())), + gray("Shift+Left-Click custom types to select them."), + selectedForDeletion.isEmpty() + ? darkGray("No custom rail types selected.") + : red("Click to permanently delete all selected types.") + ) + )); + } + + @Override + protected void setPaginatedPreviewItems(@NonNull List source) { + List> pagItems = getPageItems(source); + int slot = 0; + + // Render the selection glow on a copy so deselected items lose their glow again. + for (MutablePair item : pagItems) { + ItemStack displayedItem = item.getLeft(); + + if (selectedNames.contains(item.getRight())) + displayedItem = RailTypeMenuItems.addSelectionGlow(displayedItem); + + if (selectedForDeletion.contains(item.getRight())) + displayedItem = RailTypeMenuItems.addDeletionMarker(displayedItem); + + getMenu().getSlot(slot).setItem(displayedItem); + slot++; + } + } + + @Override + protected void setPaginatedItemClickEventsAsync(@NonNull List source) { + List> pagItems = getPageItems(source); + int slot = 0; + + // Rail types are mutually exclusive, so selecting one deselects the previous one. + for (MutablePair item : pagItems) { + final int _slot = slot; + getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { + String type = item.getRight().toLowerCase(); + + if (clickInformation.getClickType() == ClickType.SHIFT_LEFT) { + toggleBulkDeleteSelection(clickPlayer, type); + return; + } + + if (clickInformation.getClickType() == ClickType.SHIFT_RIGHT) { + deleteCustomRailType(clickPlayer, type); + return; + } + + if (clickInformation.getClickType() == ClickType.RIGHT) { + openEditorForRailType(clickPlayer, type); + return; + } + + if (selectedNames.contains(type)) { + selectedNames.remove(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Deselected rail type '%s'.", + getRailTypeDisplayName(type) + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + } else { + selectedNames.clear(); + selectedNames.add(type); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Selected rail type '%s'.", + getRailTypeDisplayName(type) + )); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + } + + reloadMenuAsync(); + }); + slot++; + } + } + + private List> getPageItems(List source) { + List> items = new ArrayList<>(); + + for (Object item : source) { + if (item instanceof MutablePair pair + && pair.getLeft() instanceof ItemStack itemStack + && pair.getRight() instanceof String identifier) + items.add(new MutablePair<>(itemStack, identifier)); + } + + return items; + } + + private String getRailTypeDisplayName(String identifier) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return identifier; + + RailType railType = rail.getRailTypeManager().byString(identifier); + return railType == null ? identifier : railType.getDisplayName(); + } + + private void openEditorForRailType(Player clickPlayer, String identifier) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + String permission = railType.isBuiltIn() ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; + + if (!RailPermissionGuard.check(clickPlayer, permission)) + return; + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + railType.isBuiltIn() + ? "Opening an editable copy of rail type '%s'." + : "Editing rail type '%s'.", + railType.getIdentifier() + )); + + // Built-in types cannot be overwritten, so editing one saves an editable copy instead. + new RailTypeEditorMenu(clickPlayer, RailTypeDraft.from(railType, !railType.isBuiltIn()), true); + } + + private void deleteCustomRailType(Player clickPlayer, String identifier) { + if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "This rail type no longer exists." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (railType.isBuiltIn()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Built-in rail types cannot be deleted. Right-click to create an editable copy." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + String error = rail.getRailTypeManager().deleteRailType(railType.getIdentifier()); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + selectedNames.remove(railType.getIdentifier()); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Deleted rail type '%s'.", railType.getIdentifier())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new RailTypeMenu(clickPlayer, true); + } + + private void toggleBulkDeleteSelection(Player clickPlayer, String identifier) { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + RailType railType = rail.getRailTypeManager().byString(identifier); + + if (railType == null || railType.isBuiltIn()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Only custom rail types can be selected for deletion." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (selectedForDeletion.remove(identifier)) + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Removed rail type '%s' from bulk deletion.", + identifier + )); + else + selectedForDeletion.add(identifier); + + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + reloadMenuAsync(false); + } + + private void deleteSelectedRailTypes(Player clickPlayer) { + if (selectedForDeletion.isEmpty()) { + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + int amount = selectedForDeletion.size(); + String error = rail.getRailTypeManager().deleteRailTypes(selectedForDeletion); + + if (error != null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent(error))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + selectedNames.removeAll(selectedForDeletion); + selectedForDeletion.clear(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Deleted %s custom rail types.", amount)); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTypeMenu(clickPlayer, searchQuery, selectedForDeletion, true); + } + @Override protected void setItemClickEventsAsync() { super.setItemClickEventsAsync(); + setRailTypePageClickEvents(); + + getMenu().getSlot(SEARCH_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (clickInformation.getClickType() == ClickType.RIGHT) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + new RailTypeMenu(clickPlayer, "", selectedForDeletion, true); + return; + } + + openSearchEditor(clickPlayer); + }); + + getMenu().getSlot(CREATE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_CREATE)) + return; + + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Creating a new rail type.")); + + new RailTypeEditorMenu(clickPlayer, new RailTypeDraft(), true); + }); + + getMenu().getSlot(RELOAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + Rail rail = GeneratorModule.getInstance().getRail(); + + if (rail == null) + return; + + rail.getRailTypeManager().reload(); + + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + clickPlayer.sendMessage(ChatHelper.getStandardComponent( + true, + "Reloaded %s rail types from rail-types.yml.", + rail.getRailTypeManager().getRailTypes().size() + )); + + new RailTypeMenu(clickPlayer, searchQuery, selectedForDeletion, true); + }); + + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + deleteSelectedRailTypes(clickPlayer)); + if (canProceed()) - getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - Settings settings = GeneratorModule.getInstance().getRail().getPlayerSettings().get(clickPlayer.getUniqueId()); + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + generateWithSelectedType(clickPlayer)); + } - if (!(settings instanceof RailSettings railSettings)) - return; + private void generateWithSelectedType(Player clickPlayer) { + if (selectedNames.isEmpty()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "Select a rail type before generating." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + reloadMenuAsync(); + return; + } - railSettings.setValue(RailFlag.RAIL_TYPE, selectedNames.getFirst()); + Rail rail = GeneratorModule.getInstance().getRail(); - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); + if (rail == null) + return; - GeneratorModule.getInstance().getRail().generate(clickPlayer); - }); + Settings settings = rail.getPlayerSettings().get(clickPlayer.getUniqueId()); + + if (!(settings instanceof RailSettings railSettings)) + return; + + RailType railType = RailType.byString(selectedNames.getFirst()); + + if (railType == null) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "This rail type no longer exists." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + railSettings.setValue(RailFlag.RAIL_TYPE, railType); + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + rail.generate(clickPlayer); + } + + private void openSearchEditor(Player clickPlayer) { + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + new AnvilGUI.Builder() + .onClick((slot, stateSnapshot) -> { + if (slot != AnvilGUI.Slot.OUTPUT) + return List.of(); + + String query = stateSnapshot.getText().trim(); + return List.of( + AnvilGUI.ResponseAction.close(), + AnvilGUI.ResponseAction.run(() -> new RailTypeMenu( + clickPlayer, + query, + selectedForDeletion, + true + )) + ); + }) + .text(searchQuery.isBlank() ? "Search" : searchQuery) + .itemLeft(Item.create(Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Search Rail Types"))) + .title(darkGray("Search rail types")) + .plugin(BuildTeamTools.getInstance()) + .open(clickPlayer); + } + + private void setRailTypePageItems() { + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT - 1).setItem(createPreviousPageItem()); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.PAPER.get()), + yellow("Current Page ") + gray("- ") + white(String.valueOf(getPage())), + List.of(gray("Use the arrows next to this item to browse rail types.")) + )); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT + 1).setItem(createNextPageItem()); + } + + private ItemStack createPreviousPageItem() { + if (!hasPreviousPage()) + return Item.create( + Objects.requireNonNull(XMaterial.GRAY_STAINED_GLASS_PANE.get()), + gray("No Previous Page"), + List.of(darkGray("You are already on the first page.")) + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_LEFT, + yellow("Previous Page ") + gray("- ") + white(String.valueOf(getPage() - 1)), + new ArrayList<>(List.of(gray("Click to show earlier rail types."))) + ); + } + + private ItemStack createNextPageItem() { + if (!hasNextPage()) + return Item.create( + Objects.requireNonNull(XMaterial.GRAY_STAINED_GLASS_PANE.get()), + gray("No Next Page"), + List.of(darkGray("There are no more rail types.")) + ); + + return HeadFactory.head( + HeadTexture.WHITE_ARROW_RIGHT, + yellow("Next Page ") + gray("- ") + white(String.valueOf(getPage() + 1)), + new ArrayList<>(List.of(gray("Click to show more rail types."))) + ); + } + + private void setRailTypePageClickEvents() { + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT - 1).setClickHandler((clickPlayer, clickInformation) -> { + if (!hasPreviousPage()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "You are already on the first rail type page." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + previousPage(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Opened rail type page %s.", getPage())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + }); + + getMenu().getSlot(SWITCH_PAGE_ITEM_SLOT + 1).setClickHandler((clickPlayer, clickInformation) -> { + if (!hasNextPage()) { + clickPlayer.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( + "There is no next rail type page." + ))); + playSound(clickPlayer, Sound.ENTITY_ITEM_BREAK); + return; + } + + nextPage(); + clickPlayer.sendMessage(ChatHelper.getStandardComponent(true, "Opened rail type page %s.", getPage())); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + }); + } + + private void playSound(Player player, Sound sound) { + player.playSound(player, sound, 1.0F, 1.0F); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + + private static String green(String text) { + return RailMenuText.color(NamedTextColor.GREEN, text); + } + + private static String red(String text) { + return RailMenuText.color(NamedTextColor.RED, text); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java new file mode 100644 index 00000000..699f15a0 --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java @@ -0,0 +1,228 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.alpsbte.alpslib.utils.item.Item; +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** Builds rail type icons and their visual selection states. */ +final class RailTypeMenuItems { + + private RailTypeMenuItems() { + } + + static ItemStack createRailTypeItem(RailType railType) { + Material icon = railType.getIcon().get(); + + if (icon == null) + icon = Objects.requireNonNull(XMaterial.RAIL.get()); + + List lore = createConfigurationLore(railType); + + if (railType.isBuiltIn()) + lore.add(darkGray("Right-Click to create an editable copy")); + else { + lore.add(darkGray("Right-Click to edit ") + gray("- ") + darkGray("Shift+Right-Click to delete")); + lore.add(darkGray("Shift+Left-Click to select for bulk deletion")); + } + + return Item.create(icon, yellow(railType.getDisplayName()), lore); + } + + private static List createConfigurationLore(RailType railType) { + List lore = new ArrayList<>(); + lore.add(gray("Rail Block: ") + white(formatMaterial(railType.getRailBlock()))); + lore.add(gray("Blocks Below: ") + formatMaterials(railType.getBlocksBelow())); + addSleeperLore(lore, railType); + addTrackLore(lore, railType); + addOverheadLore(lore, railType); + lore.add(gray("Track Switches: ") + white(railType.isTrackSwitchesEnabled() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + return lore; + } + + private static void addSleeperLore(List lore, RailType railType) { + if (!railType.hasSleepers()) { + lore.add(gray("Sleepers: ") + white("None")); + return; + } + + lore.add(gray("Sleepers: ") + + white(formatMaterial(railType.getSleeperBlock())) + + gray(" every ") + + white(String.valueOf(railType.getSleeperSpacing())) + + gray(" blocks")); + } + + private static void addTrackLore(List lore, RailType railType) { + if (railType.getTrackCount() == 1) { + lore.add(gray("Tracks: ") + white("1")); + return; + } + + lore.add(gray("Tracks: ") + + white(String.valueOf(railType.getTrackCount())) + + gray(" with spacing ") + + white(railType.getTrackSpacings().toString())); + } + + private static void addOverheadLore(List lore, RailType railType) { + lore.add(gray("Overhead Poles: ") + white(railType.hasOverheadPoles() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + + if (railType.hasOverheadPoles()) { + lore.add(gray("Pole Block: ") + white(formatMaterial(railType.getOverheadPoleBlock()))); + lore.add(gray("Top Support: ") + white(formatMaterial(railType.getOverheadSupportBlock()))); + lore.add(gray("Pole Spacing: ") + white(String.valueOf(railType.getOverheadPoleSpacing()))); + } + + lore.add(gray("Overhead Wires: ") + white(railType.hasOverheadWires() + ? RailMenuText.ENABLED + : RailMenuText.DISABLED)); + } + + static ItemStack addSelectionGlow(ItemStack item) { + ItemStack displayedItem = createGlintCompatibleDisplayItem(item); + displayedItem.setAmount(1); + displayedItem.addUnsafeEnchantment(Enchantment.LUCK_OF_THE_SEA, 1); + + ItemMeta meta = displayedItem.getItemMeta(); + + if (meta != null) { + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + forceEnchantmentGlint(meta); + displayedItem.setItemMeta(meta); + } + + return displayedItem; + } + + /** + * Vanilla does not render enchantment glint on block-entity item models, + * even when enchantment_glint_override is true (MC-69683). Use an enchanted + * book as the selected-state icon while retaining the rail type name, lore + * and the original icon name. + */ + private static ItemStack createGlintCompatibleDisplayItem(ItemStack item) { + if (!usesBlockEntityItemRenderer(item.getType())) + return item.clone(); + + ItemMeta originalMeta = item.getItemMeta(); + Component displayName = originalMeta != null && originalMeta.hasDisplayName() + ? Objects.requireNonNull(originalMeta.displayName()) + : Component.text("Selected Rail Type", NamedTextColor.YELLOW); + List originalLore = originalMeta == null ? null : originalMeta.lore(); + List lore = originalLore != null + ? new ArrayList<>(originalLore) + : new ArrayList<>(); + lore.add(Component.text("Original icon: ", NamedTextColor.GRAY) + .append(Component.text(formatMaterialName(item.getType()), NamedTextColor.WHITE))); + lore.add(Component.text("Minecraft cannot render glint on this block-entity icon.", NamedTextColor.DARK_GRAY)); + + ItemStack displayedItem = new ItemStack(Material.ENCHANTED_BOOK); + ItemMeta displayedMeta = displayedItem.getItemMeta(); + displayedMeta.displayName(displayName); + displayedMeta.lore(lore); + displayedItem.setItemMeta(displayedMeta); + return displayedItem; + } + + private static boolean usesBlockEntityItemRenderer(Material material) { + String name = material.name(); + return name.endsWith("_CHEST") + || name.endsWith("_SHULKER_BOX") + || name.endsWith("_HEAD") + || name.endsWith("_SKULL") + || name.endsWith("_BANNER") + || name.endsWith("_BED") + || material == Material.CONDUIT + || material == Material.DECORATED_POT; + } + + private static String formatMaterialName(Material material) { + return formatWords(material.name()); + } + + /** Paper 1.20.5+ exposes an explicit glint override; reflection preserves compatibility with older servers. */ + private static void forceEnchantmentGlint(ItemMeta meta) { + try { + ItemMeta.class + .getMethod("setEnchantmentGlintOverride", Boolean.class) + .invoke(meta, Boolean.TRUE); + } catch (ReflectiveOperationException ignored) { + // The hidden unsafe enchantment remains the fallback on older servers. + } + } + + static ItemStack addDeletionMarker(ItemStack item) { + ItemStack displayedItem = addSelectionGlow(item); + ItemMeta meta = displayedItem.getItemMeta(); + + if (meta == null) + return displayedItem; + + List existingLore = meta.lore(); + List lore = existingLore != null + ? new ArrayList<>(existingLore) + : new ArrayList<>(); + lore.add(Component.text("Selected for bulk deletion", NamedTextColor.RED)); + meta.lore(lore); + displayedItem.setItemMeta(meta); + return displayedItem; + } + + static String formatMaterials(List materials) { + return String.join(gray(", "), materials.stream() + .map(RailTypeMenuItems::formatMaterial) + .map(RailTypeMenuItems::white) + .toList()); + } + + static String formatMaterial(@Nullable XMaterial material) { + if (material == null) + return "None"; + + return formatWords(material.name()); + } + + private static String formatWords(String value) { + String[] words = value.toLowerCase(Locale.ROOT).split("_"); + List capitalizedWords = new ArrayList<>(); + + for (String word : words) + capitalizedWords.add(word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1)); + + return String.join(" ", capitalizedWords); + } + + private static String darkGray(String text) { + return RailMenuText.color(NamedTextColor.DARK_GRAY, text); + } + + private static String gray(String text) { + return RailMenuText.color(NamedTextColor.GRAY, text); + } + + private static String white(String text) { + return RailMenuText.color(NamedTextColor.WHITE, text); + } + + private static String yellow(String text) { + return RailMenuText.color(NamedTextColor.YELLOW, text); + } + +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java index 9cba7e1b..3ea1cbf0 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java @@ -1,5 +1,6 @@ package net.buildtheearth.buildteamtools.modules.generator.menu; +import com.alpsbte.alpslib.utils.ChatHelper; import com.alpsbte.alpslib.utils.item.Item; import com.cryptomorin.xseries.XMaterial; import net.buildtheearth.buildteamtools.modules.common.CommonModule; @@ -9,6 +10,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; import net.buildtheearth.buildteamtools.modules.generator.components.house.menu.WallColorMenu; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.menu.RailTypeMenu; import net.buildtheearth.buildteamtools.modules.generator.components.road.Road; @@ -20,9 +22,11 @@ import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorCollections; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorType; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import net.buildtheearth.buildteamtools.utils.ListUtil; import net.buildtheearth.buildteamtools.utils.MenuItems; import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; @@ -37,6 +41,10 @@ public class GeneratorMenu extends AbstractMenu { public static final String GENERATOR_INV_NAME = "What do you want to generate?"; + private static final String DESCRIPTION_LABEL = color(NamedTextColor.YELLOW, "Description:"); + private static final String FEATURES_LABEL = color(NamedTextColor.YELLOW, "Features:"); + private static final String LEFT_CLICK_TO_GENERATE = color(NamedTextColor.DARK_GRAY, "Left-click to generate"); + private static final String RIGHT_CLICK_FOR_TUTORIAL = color(NamedTextColor.DARK_GRAY, "Right-click for Tutorial"); public static final int HOUSE_ITEM_SLOT = 9; public static final int ROAD_ITEM_SLOT = 11; @@ -52,113 +60,135 @@ public GeneratorMenu(Player player, boolean autoLoad) { protected void setPreviewItems() { ArrayList houseLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate basic building shells", "with multiple floors, windows and roofs", "", - "§eFeatures:", + FEATURES_LABEL, "- " + RoofType.values().length + " Roof Types", "- Custom Wall, Base and Roof Color", "- Custom Floor and Window Sizes", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack houseItem = Item.create(Objects.requireNonNull(XMaterial.BIRCH_DOOR.get()), "§cGenerate House", houseLore); + ItemStack houseItem = Item.create( + Objects.requireNonNull(XMaterial.BIRCH_DOOR.get()), + color(NamedTextColor.RED, "Generate House"), + houseLore + ); getMenu().getSlot(HOUSE_ITEM_SLOT).setItem(houseItem); ArrayList roadLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate roads and highways", "with multiple lanes and sidewalks", "", - "§eFeatures:", + FEATURES_LABEL, "- Custom Road Width and Color", "- Custom Sidewalk Width and Color", "- Custom Lane Count", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack roadItem = Item.create(Objects.requireNonNull(XMaterial.SMOOTH_STONE_SLAB.get()), "§bGenerate Road", roadLore); + ItemStack roadItem = Item.create( + Objects.requireNonNull(XMaterial.SMOOTH_STONE_SLAB.get()), + color(NamedTextColor.AQUA, "Generate Road"), + roadLore + ); getMenu().getSlot(ROAD_ITEM_SLOT).setItem(roadItem); ArrayList railwayLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate a predefined railway", "from your active WorldEdit selection", "", - "§eSupported selections:", + color(NamedTextColor.YELLOW, "Supported selections:"), "- Cuboid", "- Polygonal", "- Convex", "", - "§eFeatures:", + FEATURES_LABEL, "- Rail Type selection", "- Straight sections", "- Direction changes", "- Automatic side block orientation", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack railwayItem = Item.create(Objects.requireNonNull(XMaterial.RAIL.get()), "§9Generate Railway", railwayLore); + ItemStack railwayItem = Item.create( + Objects.requireNonNull(XMaterial.RAIL.get()), + color(NamedTextColor.BLUE, "Generate Railway"), + railwayLore + ); getMenu().getSlot(RAIL_ITEM_SLOT).setItem(railwayItem); if (!CommonModule.getInstance().getDependencyComponent().isSchematicBrushEnabled()) { ArrayList treeLore = ListUtil.createList( "", - "§cPlugin §eSchematicBrush §cis not installed", - "§cTree Generator is disabled", + color(NamedTextColor.RED, "Plugin ") + + color(NamedTextColor.YELLOW, "SchematicBrush ") + + color(NamedTextColor.RED, "is not installed"), + color(NamedTextColor.RED, "Tree Generator is disabled"), "", - "§8Leftclick for Installation Instructions" + color(NamedTextColor.DARK_GRAY, "Leftclick for Installation Instructions") ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest §c(DISABLED)", treeLore); + ItemStack treeItem = createTreeItem(color(NamedTextColor.RED, " (DISABLED)"), treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } else if (!GeneratorCollections.hasUpdatedGeneratorCollections(getMenuPlayer())) { ArrayList treeLore = ListUtil.createList( "", - "§cThe §eTree Pack " + Tree.TREE_PACK_VERSION + " §cis not installed", - "§cTree Generator is disabled", + color(NamedTextColor.RED, "The ") + + color(NamedTextColor.YELLOW, "Tree Pack " + Tree.TREE_PACK_VERSION + " ") + + color(NamedTextColor.RED, "is not installed"), + color(NamedTextColor.RED, "Tree Generator is disabled"), "", - "§8Leftclick for Installation Instructions" + color(NamedTextColor.DARK_GRAY, "Leftclick for Installation Instructions") ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest §c(DISABLED)", treeLore); + ItemStack treeItem = createTreeItem(color(NamedTextColor.RED, " (DISABLED)"), treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } else { ArrayList treeLore = ListUtil.createList( "", - "§eDescription:", + DESCRIPTION_LABEL, "Generate trees from a set of", "hundreds of different types", "", - "§eFeatures:", + FEATURES_LABEL, "- Custom Tree Type", "", - "§8Left-click to generate", - "§8Right-click for Tutorial" + LEFT_CLICK_TO_GENERATE, + RIGHT_CLICK_FOR_TUTORIAL ); - ItemStack treeItem = Item.create(Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), "§aGenerate Tree & Forest", treeLore); + ItemStack treeItem = createTreeItem("", treeLore); getMenu().getSlot(TREE_ITEM_SLOT).setItem(treeItem); } ArrayList fieldLore = ListUtil.createList( "", - "§cThis §eGenerator §cis currently broken", - "§cField Generator is disabled", + color(NamedTextColor.RED, "This ") + + color(NamedTextColor.YELLOW, "Generator ") + + color(NamedTextColor.RED, "is currently broken"), + color(NamedTextColor.RED, "Field Generator is disabled"), "", - "§8If you want to help fixing ask on Dev Hub!" + color(NamedTextColor.DARK_GRAY, "If you want to help fixing ask on Dev Hub!") ); - ItemStack fieldItem = Item.create(Objects.requireNonNull(XMaterial.WHEAT.get()), "§6Generate Field §c(DISABLED)", fieldLore); + ItemStack fieldItem = Item.create( + Objects.requireNonNull(XMaterial.WHEAT.get()), + color(NamedTextColor.GOLD, "Generate Field ") + color(NamedTextColor.RED, "(DISABLED)"), + fieldLore + ); getMenu().getSlot(FIELD_ITEM_SLOT).setItem(fieldItem); super.setPreviewItems(); @@ -171,77 +201,98 @@ protected void setMenuItemsAsync() { @Override protected void setItemClickEventsAsync() { - getMenu().getSlot(HOUSE_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.HOUSE); - return; - } - - House house = GeneratorModule.getInstance().getHouse(); - house.getPlayerSettings().put(clickPlayer.getUniqueId(), new HouseSettings(clickPlayer)); - - if (!house.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new WallColorMenu(clickPlayer, true); - })); - - getMenu().getSlot(ROAD_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.ROAD); - return; - } - - Road road = GeneratorModule.getInstance().getRoad(); - road.getPlayerSettings().put(clickPlayer.getUniqueId(), new RoadSettings(clickPlayer)); - - if (!road.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new RoadColorMenu(clickPlayer, true); - })); - - getMenu().getSlot(RAIL_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.RAIL); - return; - } - - Rail rail = GeneratorModule.getInstance().getRail(); - rail.getPlayerSettings().put(clickPlayer.getUniqueId(), new RailSettings(clickPlayer)); - - if (!rail.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new RailTypeMenu(clickPlayer, true); - })); - - getMenu().getSlot(TREE_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - if (clickInformation.getClickType().equals(ClickType.RIGHT)) { - sendMoreInformation(clickPlayer, GeneratorType.TREE); - return; - } - - Tree tree = GeneratorModule.getInstance().getTree(); - tree.getPlayerSettings().put(clickPlayer.getUniqueId(), new TreeSettings(clickPlayer)); - - if (!tree.checkForPlayer(clickPlayer)) - return; - - clickPlayer.closeInventory(); - clickPlayer.playSound(clickPlayer.getLocation(), Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); - new TreeTypeMenu(clickPlayer, true); - })); - - getMenu().getSlot(FIELD_ITEM_SLOT).setClickHandler(((clickPlayer, clickInformation) -> { - sendMoreInformation(clickPlayer, GeneratorType.FIELD); - })); + getMenu().getSlot(HOUSE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleHouseClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(ROAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleRoadClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(RAIL_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleRailClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(TREE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + handleTreeClick(clickPlayer, clickInformation.getClickType())); + getMenu().getSlot(FIELD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> + sendMoreInformation(clickPlayer, GeneratorType.FIELD)); + } + + private void handleHouseClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.HOUSE)) + return; + + House house = GeneratorModule.getInstance().getHouse(); + house.getPlayerSettings().put(player.getUniqueId(), new HouseSettings(player)); + + if (!house.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new WallColorMenu(player, true); + } + + private void handleRoadClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.ROAD)) + return; + + Road road = GeneratorModule.getInstance().getRoad(); + road.getPlayerSettings().put(player.getUniqueId(), new RoadSettings(player)); + + if (!road.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new RoadColorMenu(player, true); + } + + private void handleRailClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.RAIL) + || !RailPermissionGuard.check(player, Permissions.RAIL_TYPE_MENU)) + return; + + Rail rail = GeneratorModule.getInstance().getRail(); + rail.getPlayerSettings().put(player.getUniqueId(), new RailSettings(player)); + + if (!rail.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new RailTypeMenu(player, true); + } + + private void handleTreeClick(Player player, ClickType clickType) { + if (showTutorialForRightClick(player, clickType, GeneratorType.TREE)) + return; + + Tree tree = GeneratorModule.getInstance().getTree(); + tree.getPlayerSettings().put(player.getUniqueId(), new TreeSettings(player)); + + if (!tree.checkForPlayer(player)) + return; + + closeMenuWithClickSound(player); + new TreeTypeMenu(player, true); + } + + private boolean showTutorialForRightClick(Player player, ClickType clickType, GeneratorType generatorType) { + if (clickType != ClickType.RIGHT) + return false; + + sendMoreInformation(player, generatorType); + return true; + } + + private void closeMenuWithClickSound(Player player) { + player.closeInventory(); + player.playSound(player, Sound.UI_BUTTON_CLICK, 1.0F, 1.0F); + } + + private ItemStack createTreeItem(String suffix, ArrayList lore) { + return Item.create( + Objects.requireNonNull(XMaterial.OAK_SAPLING.get()), + color(NamedTextColor.GREEN, "Generate Tree & Forest") + suffix, + lore + ); + } + + private static String color(NamedTextColor color, String text) { + return ChatHelper.getColorizedString(color, text, false); } private void sendMoreInformation(@NonNull Player clickPlayer, @NonNull GeneratorType generator) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java index d42f21b7..3914f724 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/FlagType.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.field.CropStage; import net.buildtheearth.buildteamtools.modules.generator.components.field.CropType; import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeWidth; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java index 1b894500..4736006e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/model/Settings.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.field.CropStage; import net.buildtheearth.buildteamtools.modules.generator.components.field.CropType; import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailType; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.tree.TreeWidth; import org.bukkit.block.Block; import org.bukkit.entity.Player; diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java index 4d07d73d..a32995be 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java @@ -15,6 +15,12 @@ public class Permissions { public static final String GENERATOR_USE = "btt.generator.use"; + public static final String RAIL_GENERATOR_USE = "btt.generator.rail.use"; + public static final String RAIL_TYPE_MENU = "btt.generator.rail.menu"; + public static final String RAIL_TYPE_CREATE = "btt.generator.rail.create"; + public static final String RAIL_TYPE_EDIT = "btt.generator.rail.edit"; + public static final String RAIL_TYPE_DELETE = "btt.generator.rail.delete"; + public static final String RAIL_MULTIPLE_TRACKS = "btt.generator.rail.multiple"; public static final String BLOCK_PALETTE_EDIT = "btt.bp.edit"; diff --git a/src/main/resources/modules/generator/config.yml b/src/main/resources/modules/generator/config.yml index 86e13d9f..0aaa5301 100644 --- a/src/main/resources/modules/generator/config.yml +++ b/src/main/resources/modules/generator/config.yml @@ -7,12 +7,12 @@ # ---------------------------------------------------------------------------------------------- rail: - # Conservative defaults for a 4 GB server. Lower these if rail generation is too heavy for your server. - max-control-points: 1000 - max-path-points: 24000 - max-block-placements: 150000 - max-prepared-region-volume: 1500000 - max-prepared-region-axis-length: 1024 + # Upper safe defaults. Lower these if rail generation is too heavy for your server. + max-control-points: 2000 + max-path-points: 75000 + max-block-placements: 300000 + max-prepared-region-volume: 6000000 + max-prepared-region-axis-length: 2048 block-placement-batch-size: 750 # NOTE: Do not change diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9306cb41..7ce8bb75 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -58,6 +58,9 @@ permissions: default: op children: btt.generator.use: true + btt.generator.rail.use: true + btt.generator.rail.menu: true + btt.generator.rail.multiple: true btt.permpack.admin: description: Contains all permissions for admins default: op @@ -77,6 +80,28 @@ permissions: btt.warp.group.delete: true btt.notify.update: true btt.bp.edit: true + btt.generator.rail.create: true + btt.generator.rail.edit: true + btt.generator.rail.delete: true + + btt.generator.rail.use: + description: Allows players to use the rail generator + default: op + btt.generator.rail.menu: + description: Allows players to open the rail type menu + default: op + btt.generator.rail.create: + description: Allows players to create custom rail types + default: op + btt.generator.rail.edit: + description: Allows players to edit custom rail types + default: op + btt.generator.rail.delete: + description: Allows players to delete custom rail types + default: op + btt.generator.rail.multiple: + description: Allows players to generate multiple parallel tracks + default: op btt.bp.use: description: Allows players to use /blockpalette From 9ef4742a732f124fdad9bc3412ca5c761a03eab7 Mon Sep 17 00:00:00 2001 From: Jasupa Date: Sat, 5 Sep 2026 14:25:34 +0200 Subject: [PATCH 2/3] fix(rail): preserve ballast mixes and enforce generation safeguards Preserve the complete ordered ballast list, including repeated materials, when editing or copying a type. The single-block picker replaces that list only after an explicit selection, and its description explains this behavior. Persist the complete draft list and cover editing, copying and replacement with regression tests. Replace the rail-specific permission guard with Utils.checkPermission(CommandSender, String), reusing the existing denial-message logic. Require the rail type edit permission before reloading configuration from disk. Abort with a player-facing error when terrain preparation returns null. Remove the incomplete five-block-per-track estimate: prepared-region limits and the exact final placement count remain authoritative, including sleepers and overhead structures. Build the track-spacing minus and plus items directly because the center item opens the individual-gap editor. This avoids constructing an item that is immediately overwritten. Validation: Gradle build, including the test suite and Shadow packaging, passed with the original build configuration. In-game behavior still requires server testing. --- .../generator/components/rail/Rail.java | 3 +- .../components/rail/RailPermissionGuard.java | 20 -------- .../rail/generation/RailScripts.java | 20 +++----- .../components/rail/menu/RailTypeDraft.java | 13 ++++- .../rail/menu/RailTypeEditorMenu.java | 14 ++--- .../components/rail/menu/RailTypeMenu.java | 13 +++-- .../modules/generator/menu/GeneratorMenu.java | 4 +- .../buildteamtools/utils/Utils.java | 9 ++++ .../rail/menu/RailTypeDraftTest.java | 51 +++++++++++++++++++ 9 files changed, 98 insertions(+), 49 deletions(-) delete mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java create mode 100644 src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java index 1227d88e..13de8d5c 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java @@ -7,6 +7,7 @@ import com.sk89q.worldedit.regions.Polygonal2DRegion; import com.sk89q.worldedit.regions.Region; import lombok.Getter; +import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; @@ -59,7 +60,7 @@ private boolean isSupportedRailSelection(Region region) { @Override public void generate(Player player) { - if (!RailPermissionGuard.check(player, Permissions.RAIL_GENERATOR_USE)) + if (!Utils.checkPermission(player, Permissions.RAIL_GENERATOR_USE)) return; if (GeneratorModule.getInstance().isGenerating(player) || !preparingPlayers.add(player.getUniqueId())) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java deleted file mode 100644 index 1f3411ae..00000000 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/RailPermissionGuard.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.buildtheearth.buildteamtools.modules.generator.components.rail; - -import com.alpsbte.alpslib.utils.ChatHelper; -import lombok.experimental.UtilityClass; -import org.bukkit.entity.Player; - -@UtilityClass -public class RailPermissionGuard { - - public static boolean check(Player player, String permission) { - if (player.hasPermission(permission)) - return true; - - player.sendMessage(ChatHelper.PREFIX_COMPONENT.append(ChatHelper.getErrorComponent( - "You don't have permission to do this. Required permission: %s", - permission - ))); - return false; - } -} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java index 210b7eb4..6cc22456 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java @@ -7,7 +7,7 @@ import com.sk89q.worldedit.world.block.BlockState; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; @@ -119,7 +119,6 @@ private boolean prepareSession() { if (!hasValidCenterPath()) return false; preparationProgress.startStage(PATH_PROGRESS, SAFETY_CHECK_PROGRESS, SAFETY_CHECK_ESTIMATED_MILLIS); - if (!hasSafeEstimatedBlockCount(centerPath)) return false; int selectionMinY = getSelectionMinY(controlPoints); int selectionMaxY = getSelectionMaxY(controlPoints); @@ -148,6 +147,11 @@ private boolean prepareSession() { false, false ); + if (blocks == null) { + sendRailError("Region not readable. Please report this to the developers of the BuildTeamTool plugin."); + return false; + } + terrainResolver = new RailTerrainResolver(blocks); preparationProgress.completeStage(TERRAIN_PREPARE_PROGRESS); @@ -310,16 +314,6 @@ private boolean hasValidCenterPath() { return true; } - private boolean hasSafeEstimatedBlockCount(List path) { - long estimatedBlocks = (long) path.size() * trackCount * 5L; - - if (estimatedBlocks <= limits.maxBlockPlacements()) - return true; - - sendRailError("Rail Generator would likely place too many blocks. Split the rail into smaller selections."); - return false; - } - private boolean hasSafePreparedSelection(List selectionPoints, int minY, int maxY) { if (selectionPoints.size() < 2) { sendRailError("Rail Generator could not create a safe preparation selection."); @@ -470,7 +464,7 @@ private boolean resolveTrackLayout() { } return trackCount == RailType.MIN_TRACK_COUNT - || RailPermissionGuard.check(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); + || Utils.checkPermission(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); } private Integer getIntegerSetting(RailFlag flag) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java index b79f610d..9210408f 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java @@ -18,7 +18,7 @@ class RailTypeDraft { private String displayName; private XMaterial icon = XMaterial.RAIL; private XMaterial railBlock = XMaterial.ANVIL; - private XMaterial blockBelow = XMaterial.GRAVEL; + private List blocksBelow = List.of(XMaterial.GRAVEL); private XMaterial sleeperBlock = XMaterial.SPRUCE_PLANKS; private int sleeperSpacing = RailType.MIN_SLEEPER_SPACING; private int trackCount = RailType.DEFAULT_TRACK_COUNT; @@ -45,7 +45,7 @@ static RailTypeDraft from(RailType railType, boolean keepIdentity) { draft.icon = railType.getIcon(); draft.railBlock = railType.getRailBlock(); - draft.blockBelow = blocksBelow.isEmpty() ? XMaterial.GRAVEL : blocksBelow.getFirst(); + draft.blocksBelow = blocksBelow.isEmpty() ? List.of(XMaterial.GRAVEL) : List.copyOf(blocksBelow); draft.sleeperBlock = railType.getSleeperBlock() == null ? XMaterial.SPRUCE_PLANKS : railType.getSleeperBlock(); draft.sleeperSpacing = railType.getSleeperSpacing(); draft.trackCount = railType.getTrackCount(); @@ -69,6 +69,15 @@ static RailTypeDraft from(RailType railType, boolean keepIdentity) { return draft; } + XMaterial getBlockBelow() { + return blocksBelow.getFirst(); + } + + // The picker replaces the mix only after the user explicitly chooses a block. + void setBlockBelow(XMaterial blockBelow) { + blocksBelow = List.of(blockBelow); + } + void setTrackCount(int trackCount) { this.trackCount = trackCount; resizeTrackSpacings(); diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java index 896b16a9..605802d8 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; @@ -82,8 +82,10 @@ protected void setPreviewItems() { createCounter(HeadColor.WHITE, TRACK_COUNT_SLOT, "Track Count", draft.getTrackCount(), RailType.MIN_TRACK_COUNT, RailType.MAX_TRACK_COUNT, "Tracks"); - createCounter(HeadColor.LIGHT_GRAY, TRACK_SPACING_SLOT, "Track Spacing", draft.getTrackSpacing(), - RailType.MIN_TRACK_SPACING, RailType.MAX_TRACK_SPACING, "Blocks"); + getMenu().getSlot(TRACK_SPACING_SLOT - 1).setItem(HeadFactory.getCounterMinusItem( + HeadColor.LIGHT_GRAY, "Track Spacing", draft.getTrackSpacing(), RailType.MIN_TRACK_SPACING)); + getMenu().getSlot(TRACK_SPACING_SLOT + 1).setItem(HeadFactory.getCounterPlusItem( + HeadColor.LIGHT_GRAY, "Track Spacing", draft.getTrackSpacing(), RailType.MAX_TRACK_SPACING)); getMenu().getSlot(TRACK_SPACING_SLOT).setItem(Item.create( Objects.requireNonNull(XMaterial.NAME_TAG.get()), yellow("Track Spacings"), @@ -104,7 +106,7 @@ protected void setPreviewItems() { getMenu().getSlot(BLOCK_BELOW_SLOT).setItem(createBlockItem( "Block Below the Rails", draft.getBlockBelow(), - "The block placed between and below the rails." + "Choosing a block replaces the saved ballast mix." )); getMenu().getSlot(SLEEPER_BLOCK_SLOT).setItem(createBlockItem( "Sleeper Block", @@ -240,7 +242,7 @@ private void openNameEditor(Player clickPlayer) { private void saveRailType(Player clickPlayer) { String permission = draft.getIdentifier() == null ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; - if (!RailPermissionGuard.check(clickPlayer, permission)) + if (!Utils.checkPermission(clickPlayer, permission)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -259,7 +261,7 @@ private void saveRailType(Player clickPlayer) { .displayName(displayName) .icon(draft.getIcon()) .railBlock(draft.getRailBlock()) - .blocksBelow(List.of(draft.getBlockBelow())) + .blocksBelow(draft.getBlocksBelow()) .sleeperBlock(draft.getSleeperBlock()) .sleeperSpacing(draft.getSleeperSpacing()) .trackCount(draft.getTrackCount()) diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java index 945c88e7..ea196496 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java @@ -7,7 +7,7 @@ import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.menu.GeneratorMenu; @@ -259,7 +259,7 @@ private void openEditorForRailType(Player clickPlayer, String identifier) { String permission = railType.isBuiltIn() ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; - if (!RailPermissionGuard.check(clickPlayer, permission)) + if (!Utils.checkPermission(clickPlayer, permission)) return; clickPlayer.closeInventory(); @@ -277,7 +277,7 @@ private void openEditorForRailType(Player clickPlayer, String identifier) { } private void deleteCustomRailType(Player clickPlayer, String identifier) { - if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -353,7 +353,7 @@ private void deleteSelectedRailTypes(Player clickPlayer) { return; } - if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -394,7 +394,7 @@ protected void setItemClickEventsAsync() { }); getMenu().getSlot(CREATE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - if (!RailPermissionGuard.check(clickPlayer, Permissions.RAIL_TYPE_CREATE)) + if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_CREATE)) return; clickPlayer.closeInventory(); @@ -405,6 +405,9 @@ protected void setItemClickEventsAsync() { }); getMenu().getSlot(RELOAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_EDIT)) + return; + Rail rail = GeneratorModule.getInstance().getRail(); if (rail == null) diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java index 3ea1cbf0..e20b6287 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java @@ -10,7 +10,7 @@ import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; import net.buildtheearth.buildteamtools.modules.generator.components.house.menu.WallColorMenu; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; -import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailPermissionGuard; +import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.menu.RailTypeMenu; import net.buildtheearth.buildteamtools.modules.generator.components.road.Road; @@ -243,7 +243,7 @@ private void handleRoadClick(Player player, ClickType clickType) { private void handleRailClick(Player player, ClickType clickType) { if (showTutorialForRightClick(player, clickType, GeneratorType.RAIL) - || !RailPermissionGuard.check(player, Permissions.RAIL_TYPE_MENU)) + || !Utils.checkPermission(player, Permissions.RAIL_TYPE_MENU)) return; Rail rail = GeneratorModule.getInstance().getRail(); diff --git a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java index 8da47c61..37c5c6bc 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java +++ b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java @@ -133,6 +133,15 @@ public static List getTabCompleterArgs(String[] args, String parentArg, return null; } + /** Checks permission and sends the standard denial message when access is denied. */ + public static boolean checkPermission(CommandSender sender, String permission) { + if (sender.hasPermission(permission)) + return true; + + sendNoPermissionMessage(sender, permission); + return false; + } + public static void sendNoPermissionMessage(CommandSender sender, String permission) { sender.sendMessage(ChatHelper.getErrorComponent("You don't have permission to execute this command. Required " + "permission: " + permission)); diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java new file mode 100644 index 00000000..723e59af --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java @@ -0,0 +1,51 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.menu; + +import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class RailTypeDraftTest { + + private RailType mixedType() { + return RailType.createCustom(new RailType.Configuration() + .identifier("mixed").displayName("Mixed ballast") + .blocksBelow(List.of(XMaterial.GRAVEL, XMaterial.STONE, XMaterial.GRAVEL)) + .trackCount(2).trackSpacing(5)); + } + + @Test + void unrelatedEditsPreserveBallastOrderAndWeights() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, true); + draft.setDisplayName("Renamed railway"); + draft.setTrackCount(3); + draft.setOverheadWiresEnabled(true); + + assertEquals(original.getBlocksBelow(), draft.getBlocksBelow()); + assertEquals("mixed", draft.getIdentifier()); + } + + @Test + void copyingATypePreservesTheEntireMix() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, false); + + assertNull(draft.getIdentifier()); + assertEquals(original.getBlocksBelow(), draft.getBlocksBelow()); + } + + @Test + void choosingBallastExplicitlyReplacesTheMix() { + RailType original = mixedType(); + RailTypeDraft draft = RailTypeDraft.from(original, true); + RailBlockRole.BLOCK_BELOW.apply(draft, XMaterial.STONE); + + assertEquals(List.of(XMaterial.STONE), draft.getBlocksBelow()); + assertEquals(3, original.getBlocksBelow().size()); + } +} From 9f52c6ca0d35a0288ce1d28603fde24290467db3 Mon Sep 17 00:00:00 2001 From: Jasupa Date: Sun, 6 Sep 2026 17:17:23 +0200 Subject: [PATCH 3/3] fix(rail): address menu permissions, copying, block palettes, and docs --- README.md | 2 + build.gradle.kts | 2 +- docs/rail-generator.md | 162 ++++++++++++++++++ .../commands/BuildTeamToolsCommand.java | 12 +- .../components/updater/UpdaterComponent.java | 3 +- .../generator/components/rail/Rail.java | 3 +- .../rail/configuration/RailType.java | 144 ++++++++++++---- .../rail/configuration/RailTypeManager.java | 115 ++++++++----- .../rail/generation/RailBlockBuilder.java | 24 ++- .../rail/generation/RailMaterialPalette.java | 22 +++ .../rail/generation/RailOverheadBuilder.java | 48 ++---- .../rail/generation/RailScripts.java | 4 +- .../rail/menu/RailBlockPickerMenu.java | 119 +++++++++++-- .../components/rail/menu/RailBlockRole.java | 35 +++- .../rail/menu/RailOverheadWireMenu.java | 14 +- .../components/rail/menu/RailTypeDraft.java | 66 +++++-- .../rail/menu/RailTypeEditorMenu.java | 34 ++-- .../components/rail/menu/RailTypeMenu.java | 117 ++++++++----- .../rail/menu/RailTypeMenuItems.java | 25 ++- .../modules/generator/menu/GeneratorMenu.java | 3 +- .../warps/commands/WarpCommand.java | 2 +- .../modules/network/model/Permissions.java | 15 ++ .../buildteamtools/utils/Utils.java | 15 -- .../configuration/RailPaletteStorageTest.java | 55 ++++++ .../configuration/RailTypeNamingTest.java | 22 +++ .../generation/RailMaterialPaletteTest.java | 35 ++++ .../rail/menu/RailTypeDraftTest.java | 81 ++++++++- .../network/model/PermissionsTest.java | 41 +++++ 28 files changed, 951 insertions(+), 269 deletions(-) create mode 100644 docs/rail-generator.md create mode 100644 src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java create mode 100644 src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java create mode 100644 src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java create mode 100644 src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java create mode 100644 src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java diff --git a/README.md b/README.md index aea96de7..ef371979 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@

BuildTeamTools

+[Rail Generator documentation](docs/rail-generator.md) +

An easy to use toolset for Build Teams in the BuildTheEarth project.
diff --git a/build.gradle.kts b/build.gradle.kts index 59ec765f..64fb2c31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -76,7 +76,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.2") - testRuntimeOnly(libs.paper.api) + testImplementation(libs.paper.api) } fun Project.versionDetails(): VersionDetails { diff --git a/docs/rail-generator.md b/docs/rail-generator.md new file mode 100644 index 00000000..af886987 --- /dev/null +++ b/docs/rail-generator.md @@ -0,0 +1,162 @@ +# Rail Generator + +## Introduction + +The Rail Generator creates railway tracks along a WorldEdit selection. You can use the built-in Standard type or create reusable custom types with different rail blocks, ballast palettes, sleepers, parallel tracks and overhead systems. + +## Preparation + +Use Google Maps or Google Earth to identify the centre line of the railway, then use `/tpll` to position yourself along it. See [How to use /tpll](https://resources.buildtheearth.net/doc/how-to-use-tpll-zMxUzzwEGb) and [Building Roads](https://resources.buildtheearth.net/doc/building-roads-gpryvN4ZXn) for coordinate-selection techniques that also apply to railways. + +1. Switch to `//sel convex`. +2. Left-click the first point with the WorldEdit selection tool. +3. Right-click the remaining points in path order along the railway's centre line. +4. Generate using a command, or open `/gen` and choose Rail to select or configure a type. + +Convex selections are recommended. Polygonal (`//sel poly`) and cuboid (`//sel cuboid`) selections are also supported; a cuboid is useful for connecting two points. Keep the selection close to the intended route. Large selections and overlapping parallel tracks can be rejected during preparation. + +## Commands + +| Command | Action | +| --- | --- | +| `/gen` | Opens the generator menu. Choose Rail to open the rail type menu. | +| `/gen rail` | Generates with the Standard type. | +| `/gen rail -t custom-1` | Generates with a saved type's identifier. | +| `/gen rail -t standard -c 2 -s 5` | Generates two tracks with five blocks between track centre lines. | +| `/gen undo` | Undoes the most recent generation. | +| `/gen redo` | Redoes the undone generation. | +| `/gen history` | Shows your generation history. | +| `/gen rail help`, `/gen rail info`, `/gen rail ?` | Links to the documentation. | + +`-t` selects a rail type, `-c` overrides its track count (1–8), and `-s` overrides all track spacings (3–32 blocks). Omitted options use the type's settings. Per-gap spacings can be configured in the editor. The undo and redo commands belong directly to `/gen`; `/gen rail undo` and `/gen rail redo` are not supported. + +## Rail type menu + +Left-click a type to select or deselect it, then click Next to generate. Selected types glow. Browse using the page arrows, or use Search to filter by identifier or display name. Right-click Search to clear the filter. + +Management actions are shown only when you have the corresponding permission: + +| Action | Control | Permission suffix | +| --- | --- | --- | +| Create a type | Create a Rail Type | `create` | +| Copy Standard | Right-click Standard, or press the drop key (normally Q) while hovering over it | `create` | +| Copy a custom type | Press the drop key while hovering over the type | `create` | +| Edit a custom type | Right-click the type | `edit` | +| Reload saved types | Reload Rail Types | `edit` | +| Delete a custom type | Shift+right-click the type | `delete` | +| Select types for bulk deletion | Shift+left-click custom types, then click Bulk Delete | `delete` | + +These suffixes use the prefix `btt.generator.rail.`. Deletion is permanent. Standard cannot be edited or deleted; its copy is saved as a separate custom type. Custom types are shared server-wide, rather than owned privately by their creator. + +Without permission for a type's special click action, the click selects or deselects the type as usual. Unavailable management buttons are hidden and do nothing when clicked. + +A copy retains the source's configuration, including the full ballast palette, and receives a new identifier when saved. If you leave its name unset, it uses the first available `Custom Rail N` display name. Renaming `Custom Rail 1` frees that visible name for the next unnamed type, while the original type keeps its stable identifier. + +## Custom type editor + +Configure the following settings before clicking Save Rail Type or Update Rail Type: + +| Setting | Behaviour | +| --- | --- | +| Name and menu icon | Sets the visible name and icon. | +| Rail block | One material or a mix for the rails. Anvils, chipped anvils and damaged anvils appear first in the picker. | +| Blocks below the rails | One or more ballast materials used between and below the rails. The generator distributes the palette across these positions. | +| Sleeper block and spacing | Sets one material or a mix for sleepers and their interval (0–16 blocks). A spacing of 0 disables sleepers. | +| Track count | Generates 1–8 parallel tracks. Multiple tracks require the `multiple` permission. | +| Track spacings | Use +/- for a common spacing, or click the centre control to configure each gap separately (3–32 blocks). | +| Overhead settings | Enables/disables poles and wires. Poles, top supports and wires each have an independent material mix. Also configures pole spacing (1–512), offset (1–16) and height (3–16). | +| Track switches | Stores a setting reserved for future switch generation; enabling it does not currently generate switches. | + +### Choosing blocks + +The picker opens with the current materials already selected. Click a material to select or deselect it. **Every building-material setting supports multiple materials across pages**: rails, blocks below the rails, sleepers, poles, top supports and wires. Each setting has its own mix. Only the menu icon accepts a single item. Click Next to apply the selection; Back discards changes made in the picker. At least one material must remain selected before you can apply it. + +To add your own material: + +1. Open the block picker for the setting you want to change. +2. Click a block in your inventory at the bottom, so it is attached to your mouse cursor. +3. Click **Use Your Own Block** (the hopper). Its material is added to the selection. +4. Return the item to your inventory and repeat for any additional materials. +5. Click **Next** to apply the mix, then save the rail type in the editor. + +Your inventory's blocks are also included in the displayed choices. Items are not consumed or transferred into the menu. A block held in your hotbar is not enough: it must be attached to the cursor when clicking the hopper. Non-block items are accepted only for the menu icon. This selects the material; custom item metadata and block-state properties are not copied. + +The generator distributes each mix over its block positions. Keeping the same configuration and positions produces the same material choices on repeated generations. One selected material gives a uniform result. Material order and repeated entries in a saved mix are preserved when copying or editing unrelated settings. Repeated entries give a material more weight; deselecting it removes all of its entries. Rail orientation and overhead connection properties are applied to the material chosen at each position. + +## Permissions + +| Permission | Allows | +| --- | --- | +| `btt.generator.use` | Access to generator commands and the generator menu. | +| `btt.generator.rail.use` | Rail generation. | +| `btt.generator.rail.menu` | Opening the rail type menu. | +| `btt.generator.rail.create` | Creating and copying types. | +| `btt.generator.rail.edit` | Editing custom types and reloading types from disk. | +| `btt.generator.rail.delete` | Deleting custom types, individually or in bulk. | +| `btt.generator.rail.multiple` | Generating more than one track. | + +## Saved rail types + +Custom types are stored in `plugins/BuildTeamTools/modules/generator/rail-types.yml`. Saving from the editor persists the configuration across restarts. Administrators can edit this file and use Reload Rail Types to load changes without restarting the server. + +The file stores names, icons, all material mixes, sleepers, track counts and spacings, overhead settings and the reserved switch setting. `schema-version` is managed by the plugin; do not change it manually. Standard remains available as a built-in type. + +Schema version 5 stores all building materials as lists under the existing keys. Older single-material values such as `rail-block: ANVIL` are automatically converted to a one-item list when loading an older file, and remain accepted when entered manually. Existing ballast mixes retain their order and repeated entries. + +Example custom type: + +```yaml +schema-version: 5 +rail-types: + mixed-railway: + display-name: Mixed Railway + icon: RAIL + rail-block: [ANVIL, CHIPPED_ANVIL] + blocks-below: [GRAVEL, STONE, COBBLESTONE] + sleeper-block: [SPRUCE_PLANKS, DARK_OAK_PLANKS] + sleeper-spacing: 4 + track-count: 2 + track-spacing: 5 + track-spacings: [5] + overhead: + poles: + enabled: true + block: [LIGHT_GRAY_CONCRETE, GRAY_CONCRETE] + spacing: 16 + offset: 3 + height: 6 + support: + block: [STONE, ANDESITE] + wires: + enabled: true + block: [IRON_BARS, CHAIN] + track-switches: + enabled: false +``` + +## Generation limits + +Server settings are stored in `plugins/BuildTeamTools/modules/generator/config.yml`. The following defaults match the bundled configuration: + +```yaml +rail: + max-control-points: 2000 + max-path-points: 75000 + max-block-placements: 300000 + max-prepared-region-volume: 6000000 + max-prepared-region-axis-length: 2048 + block-placement-batch-size: 750 +``` + +| Setting | Purpose | Allowed range | +| --- | --- | --- | +| `max-control-points` | Limits the selection points used to calculate the route. | 2–2,000 | +| `max-path-points` | Limits calculated path points. | 2–75,000 | +| `max-block-placements` | Limits final block placements per generation. | 1–300,000 | +| `max-prepared-region-volume` | Limits the terrain region prepared for generation. | 1–6,000,000 | +| `max-prepared-region-axis-length` | Limits the prepared region on each axis. | 1–2,048 | +| `block-placement-batch-size` | Controls how many block changes are applied per scheduler tick. | 1–2,000 | + +Values outside these ranges are clamped to the supported bounds. `config-version` is managed by the plugin and must not be changed manually. + +Lower limits on servers where large generations cause lag or memory pressure. In particular, reduce block placements, prepared region volume and batch size. Smaller batches spread placement over more ticks. These defaults are upper limits, not a guarantee of suitability for a particular amount of server memory. diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java b/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java index 072cad36..1286a6e8 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/common/commands/BuildTeamToolsCommand.java @@ -28,7 +28,7 @@ public class BuildTeamToolsCommand implements CommandExecutor, TabCompleter { public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, String @NotNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS); return true; } @@ -40,7 +40,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N switch (args[0].toLowerCase()) { case "communicators" -> { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_COMMUNICATORS); return true; } @@ -70,7 +70,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @N private static void debugCommand(@NonNull CommandSender sender, String @NonNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_DEBUG)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_DEBUG); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_DEBUG); return; } @@ -88,7 +88,7 @@ private static void debugCommand(@NonNull CommandSender sender, String @NonNull private static void cacheCommand(@NonNull CommandSender sender, String @NonNull [] args) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_CACHE)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CACHE); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CACHE); return; } @@ -110,7 +110,7 @@ private static void cacheCommand(@NonNull CommandSender sender, String @NonNull private static void reloadCommand(@NonNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_RELOAD)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_RELOAD); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_RELOAD); return; } @@ -127,7 +127,7 @@ private static void reloadCommand(@NonNull CommandSender sender) { private void updateCommand(@NotNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_UPDATE)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_UPDATE); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_UPDATE); return; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java b/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java index 828f255c..503e9656 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/common/components/updater/UpdaterComponent.java @@ -5,7 +5,6 @@ import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.ModuleComponent; import net.buildtheearth.buildteamtools.modules.network.model.Permissions; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.utils.io.ConfigPaths; import org.bukkit.Bukkit; import org.bukkit.Sound; @@ -84,7 +83,7 @@ public void update(CommandSender sender, boolean checkMsg) { public void checkForUpdates(@NonNull CommandSender sender) { if (!sender.hasPermission(Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES)) { - Utils.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES); + Permissions.sendNoPermissionMessage(sender, Permissions.BUILD_TEAM_TOOLS_CHECK_FOR_UPDATES); return; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java index 13de8d5c..9d8f0622 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/Rail.java @@ -7,7 +7,6 @@ import com.sk89q.worldedit.regions.Polygonal2DRegion; import com.sk89q.worldedit.regions.Region; import lombok.Getter; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; @@ -60,7 +59,7 @@ private boolean isSupportedRailSelection(Region region) { @Override public void generate(Player player) { - if (!Utils.checkPermission(player, Permissions.RAIL_GENERATOR_USE)) + if (!Permissions.checkPermission(player, Permissions.RAIL_GENERATOR_USE)) return; if (GeneratorModule.getInstance().isGenerating(player) || !preparingPlayers.add(player.getUniqueId())) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java index 92ee3d93..ed03ad0d 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java @@ -41,21 +41,21 @@ public final class RailType { private final String identifier; private final String displayName; private final XMaterial icon; - private final XMaterial railBlock; + private final List railBlocks; private final List blocksBelow; - private final @Nullable XMaterial sleeperBlock; + private final List sleeperBlocks; private final int sleeperSpacing; private final int trackCount; private final int trackSpacing; private final List trackSpacings; private final boolean overheadPolesEnabled; - private final @Nullable XMaterial overheadPoleBlock; - private final @Nullable XMaterial overheadSupportBlock; + private final List overheadPoleBlocks; + private final List overheadSupportBlocks; private final int overheadPoleSpacing; private final int overheadPoleOffset; private final int overheadPoleHeight; private final boolean overheadWiresEnabled; - private final @Nullable XMaterial overheadWireBlock; + private final List overheadWireBlocks; private final boolean trackSwitchesEnabled; private final boolean builtIn; @@ -65,9 +65,9 @@ public final class RailType { this.identifier = configuration.identifier(); this.displayName = configuration.displayName(); this.icon = configuration.icon(); - this.railBlock = configuration.railBlock(); + this.railBlocks = List.copyOf(configuration.railBlocks()); this.blocksBelow = configuredBlocksBelow == null ? List.of() : List.copyOf(configuredBlocksBelow); - this.sleeperBlock = configuration.sleeperBlock(); + this.sleeperBlocks = List.copyOf(configuration.sleeperBlocks()); this.sleeperSpacing = configuration.sleeperSpacing(); this.trackCount = configuration.trackCount(); this.trackSpacing = configuration.trackSpacing(); @@ -77,27 +77,47 @@ public final class RailType { configuration.trackSpacing() ); this.overheadPolesEnabled = configuration.overheadPolesEnabled(); - this.overheadPoleBlock = configuration.overheadPoleBlock(); - this.overheadSupportBlock = configuration.overheadSupportBlock(); + this.overheadPoleBlocks = List.copyOf(configuration.overheadPoleBlocks()); + this.overheadSupportBlocks = List.copyOf(configuration.overheadSupportBlocks()); this.overheadPoleSpacing = configuration.overheadPoleSpacing(); this.overheadPoleOffset = configuration.overheadPoleOffset(); this.overheadPoleHeight = configuration.overheadPoleHeight(); this.overheadWiresEnabled = configuration.overheadWiresEnabled(); - this.overheadWireBlock = configuration.overheadWireBlock(); + this.overheadWireBlocks = List.copyOf(configuration.overheadWireBlocks()); this.trackSwitchesEnabled = configuration.trackSwitchesEnabled(); this.builtIn = builtIn; } + public @Nullable XMaterial getRailBlock() { + return railBlocks.isEmpty() ? null : railBlocks.getFirst(); + } + + public @Nullable XMaterial getSleeperBlock() { + return sleeperBlocks.isEmpty() ? null : sleeperBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadPoleBlock() { + return overheadPoleBlocks.isEmpty() ? null : overheadPoleBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadSupportBlock() { + return overheadSupportBlocks.isEmpty() ? null : overheadSupportBlocks.getFirst(); + } + + public @Nullable XMaterial getOverheadWireBlock() { + return overheadWireBlocks.isEmpty() ? null : overheadWireBlocks.getFirst(); + } + public boolean hasSleepers() { - return sleeperSpacing > 0 && sleeperBlock != null; + return sleeperSpacing > 0 && !sleeperBlocks.isEmpty(); } public boolean hasOverheadPoles() { - return overheadPolesEnabled && overheadPoleBlock != null; + return overheadPolesEnabled && !overheadPoleBlocks.isEmpty(); } public boolean hasOverheadWires() { - return overheadWiresEnabled && overheadWireBlock != null; + return overheadWiresEnabled && !overheadWireBlocks.isEmpty(); } /** @@ -182,7 +202,7 @@ static RailType createStandard() { } private static @Nullable String validateBlocks(Configuration configuration) { - if (!isPlaceableBlock(configuration.railBlock())) + if (!isPlaceablePalette(configuration.railBlocks())) return "Rail block must be a valid placeable Minecraft block."; List blocksBelow = configuration.blocksBelow(); @@ -204,7 +224,8 @@ static RailType createStandard() { return "Sleeper spacing must be between %s and %s. Use 0 to disable sleepers." .formatted(MIN_SLEEPER_SPACING, MAX_SLEEPER_SPACING); - if (sleeperSpacing > 0 && !isPlaceableBlock(configuration.sleeperBlock())) + if ((sleeperSpacing > 0 || !configuration.sleeperBlocks().isEmpty()) + && !isPlaceablePalette(configuration.sleeperBlocks())) return "Sleeper block must be a valid placeable Minecraft block."; return null; @@ -252,23 +273,23 @@ private static List normalizeTrackSpacings(@Nullable List conf if (poleError != null) return poleError; - if (configuration.overheadWiresEnabled() && !isPlaceableBlock(configuration.overheadWireBlock())) + if ((configuration.overheadWiresEnabled() || !configuration.overheadWireBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadWireBlocks())) return "Overhead wire block must be a valid placeable Minecraft block."; return null; } private static @Nullable String validateOverheadPoles(Configuration configuration) { - if (!configuration.overheadPolesEnabled()) - return null; - - if (!isPlaceableBlock(configuration.overheadPoleBlock())) + if ((configuration.overheadPolesEnabled() || !configuration.overheadPoleBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadPoleBlocks())) return "Overhead pole block must be a valid placeable Minecraft block."; - if (!isPlaceableBlock(configuration.overheadSupportBlock())) + if ((configuration.overheadPolesEnabled() || !configuration.overheadSupportBlocks().isEmpty()) + && !isPlaceablePalette(configuration.overheadSupportBlocks())) return "Overhead support block must be a valid placeable Minecraft block."; - return validateOverheadPoleMeasurements(configuration); + return configuration.overheadPolesEnabled() ? validateOverheadPoleMeasurements(configuration) : null; } private static @Nullable String validateOverheadPoleMeasurements(Configuration configuration) { @@ -297,6 +318,10 @@ private static boolean hasInvalidIdentifier(@Nullable String identifier) { || !identifier.toLowerCase().matches(IDENTIFIER_PATTERN); } + private static boolean isPlaceablePalette(List materials) { + return !materials.isEmpty() && materials.stream().allMatch(RailType::isPlaceableBlock); + } + private static boolean isPlaceableBlock(@Nullable XMaterial material) { if (material == null) return false; @@ -322,21 +347,21 @@ public static final class Configuration { private @Nullable String identifier; private @Nullable String displayName; private @Nullable XMaterial icon; - private @Nullable XMaterial railBlock; + private List railBlocks = List.of(); private @Nullable List blocksBelow; - private @Nullable XMaterial sleeperBlock; + private List sleeperBlocks = List.of(); private int sleeperSpacing; private int trackCount; private int trackSpacing; private @Nullable List trackSpacings; private boolean overheadPolesEnabled; - private @Nullable XMaterial overheadPoleBlock; - private @Nullable XMaterial overheadSupportBlock; + private List overheadPoleBlocks = List.of(); + private List overheadSupportBlocks = List.of(); private int overheadPoleSpacing = DEFAULT_OVERHEAD_POLE_SPACING; private int overheadPoleOffset = DEFAULT_OVERHEAD_POLE_OFFSET; private int overheadPoleHeight = DEFAULT_OVERHEAD_POLE_HEIGHT; private boolean overheadWiresEnabled; - private @Nullable XMaterial overheadWireBlock; + private List overheadWireBlocks = List.of(); private boolean trackSwitchesEnabled; public @Nullable String identifier() { @@ -366,12 +391,21 @@ public Configuration icon(@Nullable XMaterial icon) { return this; } + public List railBlocks() { + return railBlocks; + } + + public Configuration railBlocks(List materials) { + this.railBlocks = new java.util.ArrayList<>(materials); + return this; + } + public @Nullable XMaterial railBlock() { - return railBlock; + return railBlocks.isEmpty() ? null : railBlocks.getFirst(); } public Configuration railBlock(@Nullable XMaterial railBlock) { - this.railBlock = railBlock; + this.railBlocks = railBlock == null ? List.of() : List.of(railBlock); return this; } @@ -384,12 +418,21 @@ public Configuration blocksBelow(@Nullable List blocksBelow) { return this; } + public List sleeperBlocks() { + return sleeperBlocks; + } + + public Configuration sleeperBlocks(List materials) { + this.sleeperBlocks = new java.util.ArrayList<>(materials); + return this; + } + public @Nullable XMaterial sleeperBlock() { - return sleeperBlock; + return sleeperBlocks.isEmpty() ? null : sleeperBlocks.getFirst(); } public Configuration sleeperBlock(@Nullable XMaterial sleeperBlock) { - this.sleeperBlock = sleeperBlock; + this.sleeperBlocks = sleeperBlock == null ? List.of() : List.of(sleeperBlock); return this; } @@ -438,21 +481,39 @@ public Configuration overheadPolesEnabled(boolean overheadPolesEnabled) { return this; } + public List overheadPoleBlocks() { + return overheadPoleBlocks; + } + + public Configuration overheadPoleBlocks(List materials) { + this.overheadPoleBlocks = new java.util.ArrayList<>(materials); + return this; + } + public @Nullable XMaterial overheadPoleBlock() { - return overheadPoleBlock; + return overheadPoleBlocks.isEmpty() ? null : overheadPoleBlocks.getFirst(); } public Configuration overheadPoleBlock(@Nullable XMaterial overheadPoleBlock) { - this.overheadPoleBlock = overheadPoleBlock; + this.overheadPoleBlocks = overheadPoleBlock == null ? List.of() : List.of(overheadPoleBlock); + return this; + } + + public List overheadSupportBlocks() { + return overheadSupportBlocks; + } + + public Configuration overheadSupportBlocks(List materials) { + this.overheadSupportBlocks = new java.util.ArrayList<>(materials); return this; } public @Nullable XMaterial overheadSupportBlock() { - return overheadSupportBlock; + return overheadSupportBlocks.isEmpty() ? null : overheadSupportBlocks.getFirst(); } public Configuration overheadSupportBlock(@Nullable XMaterial overheadSupportBlock) { - this.overheadSupportBlock = overheadSupportBlock; + this.overheadSupportBlocks = overheadSupportBlock == null ? List.of() : List.of(overheadSupportBlock); return this; } @@ -492,12 +553,21 @@ public Configuration overheadWiresEnabled(boolean overheadWiresEnabled) { return this; } + public List overheadWireBlocks() { + return overheadWireBlocks; + } + + public Configuration overheadWireBlocks(List materials) { + this.overheadWireBlocks = new java.util.ArrayList<>(materials); + return this; + } + public @Nullable XMaterial overheadWireBlock() { - return overheadWireBlock; + return overheadWireBlocks.isEmpty() ? null : overheadWireBlocks.getFirst(); } public Configuration overheadWireBlock(@Nullable XMaterial overheadWireBlock) { - this.overheadWireBlock = overheadWireBlock; + this.overheadWireBlocks = overheadWireBlock == null ? List.of() : List.of(overheadWireBlock); return this; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java index 1f957513..f92ee5c6 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java @@ -11,10 +11,12 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Holds all available rail types and persists player-created rail types to disk. @@ -24,7 +26,7 @@ public class RailTypeManager { private static final String FILE_PATH = "modules/generator/rail-types.yml"; private static final String SCHEMA_VERSION_KEY = "schema-version"; - static final int CURRENT_SCHEMA_VERSION = 4; + static final int CURRENT_SCHEMA_VERSION = 5; private static final String TYPES_SECTION = "rail-types"; private static final String KEY_DISPLAY_NAME = "display-name"; @@ -94,6 +96,22 @@ public String getNextCustomIdentifier() { return "custom-" + index; } + /** Names are allocated separately from stable identifiers, so renaming frees a display name. */ + public String getNextCustomDisplayName() { + return getNextCustomDisplayName(railTypes.values().stream().map(RailType::getDisplayName).toList()); + } + + static String getNextCustomDisplayName(Collection displayNames) { + Set names = new HashSet<>(); + for (String displayName : displayNames) + names.add(displayName.toLowerCase(Locale.ROOT)); + + int index = 1; + while (names.contains("custom rail " + index)) + index++; + return "Custom Rail " + index; + } + public @Nullable RailType byString(@Nullable String value) { if (value == null) return null; @@ -203,9 +221,9 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif String normalizedIdentifier = identifier.toLowerCase(Locale.ROOT); String displayName = section.getString(KEY_DISPLAY_NAME, identifier); - XMaterial railBlock = parseMaterial(section.getString(KEY_RAIL_BLOCK)); + List railBlocks = parsePalette(section.get(KEY_RAIL_BLOCK)); List blocksBelow = parseMaterials(section.getStringList(KEY_BLOCKS_BELOW)); - XMaterial sleeperBlock = parseMaterial(section.getString(KEY_SLEEPER_BLOCK)); + List sleeperBlocks = parsePalette(section.get(KEY_SLEEPER_BLOCK)); int sleeperSpacing = section.getInt(KEY_SLEEPER_SPACING, RailType.MIN_SLEEPER_SPACING); int trackCount = section.getInt(KEY_TRACK_COUNT, RailType.DEFAULT_TRACK_COUNT); int trackSpacing = section.getInt(KEY_TRACK_SPACING, RailType.DEFAULT_TRACK_SPACING); @@ -213,8 +231,8 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif ? section.getIntegerList(KEY_TRACK_SPACINGS) : Collections.nCopies(Math.max(0, trackCount - 1), trackSpacing); boolean overheadPolesEnabled = section.getBoolean(KEY_OVERHEAD_POLES_ENABLED, false); - XMaterial overheadPoleBlock = parseMaterial(section.getString(KEY_OVERHEAD_POLE_BLOCK)); - XMaterial overheadSupportBlock = parseMaterial(section.getString(KEY_OVERHEAD_SUPPORT_BLOCK)); + List overheadPoleBlocks = parsePalette(section.get(KEY_OVERHEAD_POLE_BLOCK)); + List overheadSupportBlocks = parsePalette(section.get(KEY_OVERHEAD_SUPPORT_BLOCK)); int overheadPoleSpacing = section.getInt( KEY_OVERHEAD_POLE_SPACING, RailType.DEFAULT_OVERHEAD_POLE_SPACING @@ -228,28 +246,28 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif RailType.DEFAULT_OVERHEAD_POLE_HEIGHT ); boolean overheadWiresEnabled = section.getBoolean(KEY_OVERHEAD_WIRES_ENABLED, false); - XMaterial overheadWireBlock = parseMaterial(section.getString(KEY_OVERHEAD_WIRE_BLOCK)); + List overheadWireBlocks = parsePalette(section.get(KEY_OVERHEAD_WIRE_BLOCK)); boolean trackSwitchesEnabled = section.getBoolean(KEY_TRACK_SWITCHES_ENABLED, false); String error = RailType.validate(new RailType.Configuration() .identifier(normalizedIdentifier) .displayName(displayName) - .icon(railBlock) - .railBlock(railBlock) + .icon(railBlocks.isEmpty() ? null : railBlocks.getFirst()) + .railBlocks(railBlocks) .blocksBelow(blocksBelow) - .sleeperBlock(sleeperBlock) + .sleeperBlocks(sleeperBlocks) .sleeperSpacing(sleeperSpacing) .trackCount(trackCount) .trackSpacing(trackSpacing) .trackSpacings(trackSpacings) .overheadPolesEnabled(overheadPolesEnabled) - .overheadPoleBlock(overheadPoleBlock) - .overheadSupportBlock(overheadSupportBlock) + .overheadPoleBlocks(overheadPoleBlocks) + .overheadSupportBlocks(overheadSupportBlocks) .overheadPoleSpacing(overheadPoleSpacing) .overheadPoleOffset(overheadPoleOffset) .overheadPoleHeight(overheadPoleHeight) .overheadWiresEnabled(overheadWiresEnabled) - .overheadWireBlock(overheadWireBlock) + .overheadWireBlocks(overheadWireBlocks) .trackSwitchesEnabled(trackSwitchesEnabled)); if (error != null) { @@ -267,22 +285,22 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif railTypes.put(normalizedIdentifier, new RailType(new RailType.Configuration() .identifier(normalizedIdentifier) .displayName(displayName) - .icon(icon == null ? railBlock : icon) - .railBlock(railBlock) + .icon(icon == null ? railBlocks.getFirst() : icon) + .railBlocks(railBlocks) .blocksBelow(blocksBelow) - .sleeperBlock(sleeperBlock) + .sleeperBlocks(sleeperBlocks) .sleeperSpacing(sleeperSpacing) .trackCount(trackCount) .trackSpacing(trackSpacing) .trackSpacings(trackSpacings) .overheadPolesEnabled(overheadPolesEnabled) - .overheadPoleBlock(overheadPoleBlock) - .overheadSupportBlock(overheadSupportBlock) + .overheadPoleBlocks(overheadPoleBlocks) + .overheadSupportBlocks(overheadSupportBlocks) .overheadPoleSpacing(overheadPoleSpacing) .overheadPoleOffset(overheadPoleOffset) .overheadPoleHeight(overheadPoleHeight) .overheadWiresEnabled(overheadWiresEnabled) - .overheadWireBlock(overheadWireBlock) + .overheadWireBlocks(overheadWireBlocks) .trackSwitchesEnabled(trackSwitchesEnabled), false)); } @@ -298,13 +316,10 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif config.set(path + "." + KEY_DISPLAY_NAME, railType.getDisplayName()); config.set(path + "." + KEY_ICON, railType.getIcon().name()); - config.set(path + "." + KEY_RAIL_BLOCK, railType.getRailBlock().name()); + config.set(path + "." + KEY_RAIL_BLOCK, railType.getRailBlocks().stream().map(XMaterial::name).toList()); config.set(path + "." + KEY_BLOCKS_BELOW, railType.getBlocksBelow().stream().map(XMaterial::name).toList()); - XMaterial sleeperBlock = railType.getSleeperBlock(); - - if (sleeperBlock != null) - config.set(path + "." + KEY_SLEEPER_BLOCK, sleeperBlock.name()); + config.set(path + "." + KEY_SLEEPER_BLOCK, railType.getSleeperBlocks().stream().map(XMaterial::name).toList()); config.set(path + "." + KEY_SLEEPER_SPACING, railType.getSleeperSpacing()); config.set(path + "." + KEY_TRACK_COUNT, railType.getTrackCount()); @@ -317,20 +332,11 @@ private void loadRailType(@Nullable ConfigurationSection section, String identif config.set(path + "." + KEY_OVERHEAD_WIRES_ENABLED, railType.isOverheadWiresEnabled()); config.set(path + "." + KEY_TRACK_SWITCHES_ENABLED, railType.isTrackSwitchesEnabled()); - XMaterial overheadPoleBlock = railType.getOverheadPoleBlock(); - - if (overheadPoleBlock != null) - config.set(path + "." + KEY_OVERHEAD_POLE_BLOCK, overheadPoleBlock.name()); - - XMaterial overheadSupportBlock = railType.getOverheadSupportBlock(); - - if (overheadSupportBlock != null) - config.set(path + "." + KEY_OVERHEAD_SUPPORT_BLOCK, overheadSupportBlock.name()); + config.set(path + "." + KEY_OVERHEAD_POLE_BLOCK, railType.getOverheadPoleBlocks().stream().map(XMaterial::name).toList()); - XMaterial overheadWireBlock = railType.getOverheadWireBlock(); + config.set(path + "." + KEY_OVERHEAD_SUPPORT_BLOCK, railType.getOverheadSupportBlocks().stream().map(XMaterial::name).toList()); - if (overheadWireBlock != null) - config.set(path + "." + KEY_OVERHEAD_WIRE_BLOCK, overheadWireBlock.name()); + config.set(path + "." + KEY_OVERHEAD_WIRE_BLOCK, railType.getOverheadWireBlocks().stream().map(XMaterial::name).toList()); } try { @@ -383,6 +389,7 @@ private void migrateStorage(YamlConfiguration config) { migrateVersionOneToTwo(config); migrateVersionTwoToThree(config); migrateVersionThreeToFour(config); + migrateVersionFourToFive(config); config.set(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION); try { @@ -452,31 +459,57 @@ private void migrateVersionThreeToFour(YamlConfiguration config) { } } + static List parsePalette(Object value) { + if (value == null) + return List.of(); + List values = value instanceof List list ? list : List.of(value); + List materials = new ArrayList<>(); + for (Object entry : values) + materials.add(entry instanceof String name ? XMaterial.matchXMaterial(name).orElse(null) : null); + return materials; + } + + static void migrateVersionFourToFive(YamlConfiguration config) { + ConfigurationSection types = config.getConfigurationSection(TYPES_SECTION); + if (types == null) + return; + for (String identifier : types.getKeys(false)) { + ConfigurationSection section = types.getConfigurationSection(identifier); + if (section == null) + continue; + for (String key : List.of(KEY_RAIL_BLOCK, KEY_SLEEPER_BLOCK, KEY_OVERHEAD_POLE_BLOCK, + KEY_OVERHEAD_SUPPORT_BLOCK, KEY_OVERHEAD_WIRE_BLOCK)) { + Object value = section.get(key); + if (value instanceof String) + section.set(key, List.of(value)); + } + } + } private void setDefault(ConfigurationSection section, String path, Object value) { if (!section.contains(path)) section.set(path, value); } - private RailType.Configuration toConfiguration(RailType railType) { + static RailType.Configuration toConfiguration(RailType railType) { return new RailType.Configuration() .identifier(railType.getIdentifier()) .displayName(railType.getDisplayName()) .icon(railType.getIcon()) - .railBlock(railType.getRailBlock()) + .railBlocks(railType.getRailBlocks()) .blocksBelow(railType.getBlocksBelow()) - .sleeperBlock(railType.getSleeperBlock()) + .sleeperBlocks(railType.getSleeperBlocks()) .sleeperSpacing(railType.getSleeperSpacing()) .trackCount(railType.getTrackCount()) .trackSpacing(railType.getTrackSpacing()) .trackSpacings(railType.getTrackSpacings()) .overheadPolesEnabled(railType.isOverheadPolesEnabled()) - .overheadPoleBlock(railType.getOverheadPoleBlock()) - .overheadSupportBlock(railType.getOverheadSupportBlock()) + .overheadPoleBlocks(railType.getOverheadPoleBlocks()) + .overheadSupportBlocks(railType.getOverheadSupportBlocks()) .overheadPoleSpacing(railType.getOverheadPoleSpacing()) .overheadPoleOffset(railType.getOverheadPoleOffset()) .overheadPoleHeight(railType.getOverheadPoleHeight()) .overheadWiresEnabled(railType.isOverheadWiresEnabled()) - .overheadWireBlock(railType.getOverheadWireBlock()) + .overheadWireBlocks(railType.getOverheadWireBlocks()) .trackSwitchesEnabled(railType.isTrackSwitchesEnabled()); } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java index c48ee32f..d71a54eb 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java @@ -30,7 +30,7 @@ final class RailBlockBuilder { private final RailTerrainResolver terrainResolver; private final RailType railType; - private final BlockType railBlockType; + private final List railBlockTypes; private final RailPreparationProgress preparationProgress; private final long terrainAdjustedPercentage; private final long buildFinishedPercentage; @@ -44,7 +44,7 @@ final class RailBlockBuilder { ) { this.terrainResolver = terrainResolver; this.railType = railType; - this.railBlockType = getRailBlockType(railType); + this.railBlockTypes = railType.getRailBlocks().stream().map(this::getRailBlockType).toList(); this.preparationProgress = preparationProgress; this.terrainAdjustedPercentage = terrainAdjustedPercentage; this.buildFinishedPercentage = buildFinishedPercentage; @@ -101,8 +101,6 @@ private boolean isSleeperPoint(int index) { } private void addSleeperBlocks(Map railBlocks, List path, Set centerPositions) { - BlockState sleeperBlockState = GeneratorUtils.getBlockState(railType.getSleeperBlock()); - for (int index = 0; index < path.size(); index++) { if (!isSleeperPoint(index)) continue; @@ -111,8 +109,8 @@ private void addSleeperBlocks(Map railBlocks, List centerPositions ) { if (perpendicularStep.dx() == 0 && perpendicularStep.dz() == 0) @@ -137,7 +134,7 @@ private void addSleeperBlock( if (centerPositions.contains(key) || railBlocks.containsKey(key)) return; - railBlocks.put(key, sleeperBlockState); + railBlocks.put(key, GeneratorUtils.getBlockState(RailMaterialPalette.select(railType.getSleeperBlocks(), key))); } private int getTotalPathPointCount(List> railCenterPaths) { @@ -330,7 +327,7 @@ private static RailStep getStep(Vector from, Vector to) { private BlockState createCenterBlockState(Vector position, boolean sleeperPoint) { if (sleeperPoint) - return GeneratorUtils.getBlockState(railType.getSleeperBlock()); + return GeneratorUtils.getBlockState(RailMaterialPalette.select(railType.getSleeperBlocks(), PositionKey.from(position))); List blocksBelow = railType.getBlocksBelow(); int index = Math.floorMod( @@ -345,10 +342,11 @@ private BlockState createRailBlockState( RailSideBlock sideBlock, Map sideBlocks ) { + BlockType railBlockType = RailMaterialPalette.select(railBlockTypes, sideBlock.key()); Direction preferredDirection = sideBlock.getPreferredFacing(); if (railBlockType.getPropertyMap().containsKey(SHAPE_PROPERTY)) - return createRailShapeBlockState( + return createRailShapeBlockState(railBlockType, resolveSideBlockFacing(sideBlock, sideBlocks), getConnections(sideBlock.key(), sideBlocks) ); @@ -359,7 +357,7 @@ private BlockState createRailBlockState( return GeneratorUtils.getBlockStateWithFacing(railBlockType, preferredDirection); } - private BlockState createRailShapeBlockState(Direction preferredDirection, RailConnections connections) { + private BlockState createRailShapeBlockState(BlockType railBlockType, Direction preferredDirection, RailConnections connections) { String shape = resolveRailShape(preferredDirection, connections); try { @@ -479,8 +477,8 @@ private RailConnections getConnections( return null; } - private BlockType getRailBlockType(RailType railType) { - BlockType blockType = Item.convertXMaterialToWEBlockType(railType.getRailBlock()); + private BlockType getRailBlockType(XMaterial material) { + BlockType blockType = Item.convertXMaterialToWEBlockType(material); return blockType == null ? BlockTypes.ANVIL : blockType; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java new file mode 100644 index 00000000..c36b170d --- /dev/null +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPalette.java @@ -0,0 +1,22 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import java.util.List; + +/** Stable spatial mixing: a repeated generation picks the same material at each position. */ +final class RailMaterialPalette { + private RailMaterialPalette() { + } + + static T select(List materials, int x, int y, int z) { + if (materials.isEmpty()) + throw new IllegalArgumentException("A material palette must not be empty."); + int hash = x * 73428767 ^ y * 912931 ^ z * 438289; + hash = (hash ^ (hash >>> 16)) * 0x45d9f3b; + hash ^= hash >>> 16; + return materials.get(Math.floorMod(hash, materials.size())); + } + + static T select(List materials, PositionKey position) { + return select(materials, position.x(), position.y(), position.z()); + } +} diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java index f2e3c882..f47c3609 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java @@ -2,7 +2,6 @@ import com.alpsbte.alpslib.utils.GeneratorUtils; import com.alpsbte.alpslib.utils.item.Item; -import com.cryptomorin.xseries.XMaterial; import com.fastasyncworldedit.core.registry.state.PropertyKey; import com.sk89q.worldedit.util.Direction; import com.sk89q.worldedit.world.block.BlockState; @@ -71,19 +70,11 @@ private int getOverheadY(List> railCenterPaths) { } private void addWires(Map blocks, List> railCenterPaths, int overheadY) { - XMaterial wireBlock = railType.getOverheadWireBlock(); - - if (wireBlock == null) - return; - - BlockType wireBlockType = Item.convertXMaterialToWEBlockType(wireBlock); - - if (wireBlockType == null) - return; + List wireBlockTypes = railType.getOverheadWireBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList(); for (List path : railCenterPaths) { List wirePath = createOverheadPath(path, overheadY); - addPathBlocks(blocks, wirePath, wireBlockType, false); + addPathBlocks(blocks, wirePath, wireBlockTypes, false); } } @@ -94,17 +85,7 @@ private void addPortals( int overheadY, Set trackFootprint ) { - XMaterial poleBlock = railType.getOverheadPoleBlock(); - XMaterial supportBlock = railType.getOverheadSupportBlock(); - - if (poleBlock == null || supportBlock == null) - return; - - BlockState poleState = GeneratorUtils.getBlockState(poleBlock); - BlockType supportBlockType = Item.convertXMaterialToWEBlockType(supportBlock); - - if (supportBlockType == null) - return; + List supportBlockTypes = railType.getOverheadSupportBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList(); for (int index = 0; index < leftPath.size(); index += railType.getOverheadPoleSpacing()) { Vector leftCenter = leftPath.get(index); @@ -116,9 +97,9 @@ private void addPortals( if (leftPole == null || rightPole == null) continue; - addPole(blocks, leftPole.x(), leftPole.z(), leftPole.surfaceY(), overheadY, poleState); - addPole(blocks, rightPole.x(), rightPole.z(), rightPole.surfaceY(), overheadY, poleState); - addPortalSupport(blocks, leftPole.x(), overheadY + 1, leftPole.z(), rightPole.x(), rightPole.z(), supportBlockType); + addPole(blocks, leftPole.x(), leftPole.z(), leftPole.surfaceY(), overheadY); + addPole(blocks, rightPole.x(), rightPole.z(), rightPole.surfaceY(), overheadY); + addPortalSupport(blocks, leftPole.x(), overheadY + 1, leftPole.z(), rightPole.x(), rightPole.z(), supportBlockTypes); } } @@ -160,11 +141,13 @@ private void addPole( int poleX, int poleZ, int surfaceY, - int topY, - BlockState poleState + int topY ) { - for (int y = Math.min(surfaceY, topY); y <= topY; y++) - blocks.putIfAbsent(PositionKey.of(poleX, y, poleZ), poleState); + for (int y = Math.min(surfaceY, topY); y <= topY; y++) { + PositionKey position = PositionKey.of(poleX, y, poleZ); + blocks.putIfAbsent(position, GeneratorUtils.getBlockState( + RailMaterialPalette.select(railType.getOverheadPoleBlocks(), position))); + } } private void addPortalSupport( @@ -174,11 +157,11 @@ private void addPortalSupport( int startZ, int endX, int endZ, - BlockType supportBlockType + List supportBlockTypes ) { Vector start = new Vector(startX, topY, startZ); Vector end = new Vector(endX, topY, endZ); - addPathBlocks(blocks, createOrthogonalPath(start, end), supportBlockType, true); + addPathBlocks(blocks, createOrthogonalPath(start, end), supportBlockTypes, true); } private RailStep getPerpendicularStep(List path, int index, int sideSign) { @@ -201,12 +184,13 @@ private List createOrthogonalPath(Vector start, Vector end) { private void addPathBlocks( Map blocks, List path, - BlockType blockType, + List blockTypes, boolean overwrite ) { Set positions = createConnectedPathPositions(path); for (PositionKey position : positions) { + BlockType blockType = RailMaterialPalette.select(blockTypes, position); BlockState blockState = createPathState(blockType, position, positions); if (overwrite) diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java index 6cc22456..fc19a4b4 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailScripts.java @@ -7,7 +7,6 @@ import com.sk89q.worldedit.world.block.BlockState; import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.model.GeneratorComponent; @@ -244,7 +243,6 @@ private void queueRailBlockPlacements(Map railBlocks) { List positions = new ArrayList<>(limits.blockPlacementBatchSize()); List blockStates = new ArrayList<>(limits.blockPlacementBatchSize()); - createCommand("//perf neighbors on"); for (Map.Entry entry : railBlocks.entrySet()) { positions.add(entry.getKey().toVector()); @@ -464,7 +462,7 @@ private boolean resolveTrackLayout() { } return trackCount == RailType.MIN_TRACK_COUNT - || Utils.checkPermission(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); + || Permissions.checkPermission(getPlayer(), Permissions.RAIL_MULTIPLE_TRACKS); } private Integer getIntegerSetting(RailFlag flag) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java index 8d9fbcbe..3acc0033 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java @@ -2,28 +2,86 @@ import com.alpsbte.alpslib.utils.item.Item; import com.cryptomorin.xseries.XMaterial; +import net.buildtheearth.buildteamtools.BuildTeamTools; import net.buildtheearth.buildteamtools.utils.menus.AbstractMenu; import net.buildtheearth.buildteamtools.utils.menus.BlockListMenu; +import org.bukkit.Bukkit; +import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.entity.Player; +import org.bukkit.event.Event; import org.bukkit.inventory.ItemStack; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; /** - * Lets the player pick a single block for one of the {@link RailBlockRole} slots - * of a rail type draft, then returns to the editor menu. + * Selects independent material palettes for every building role in a draft. + * Cursor items are sampled without moving or consuming them. */ public class RailBlockPickerMenu extends BlockListMenu { + private static final int CUSTOM_BLOCK_SLOT = 28; + private static final int SELECTION_SLOT = 29; + private final RailTypeDraft draft; private final RailBlockRole role; + private final List choices; RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, boolean autoLoad) { - super(player, role.getMenuTitle(), role.createChoices(), createParentMenu(player, draft, role), autoLoad); + this(player, draft, role, autoLoad, createChoices(player, draft, role)); + } + + private RailBlockPickerMenu(Player player, RailTypeDraft draft, RailBlockRole role, + boolean autoLoad, List choices) { + super(player, role.getMenuTitle(), choices, createParentMenu(player, draft, role), false); this.draft = draft; this.role = role; + this.choices = choices; + selectedMaterials = new ArrayList<>(role.getSelected(draft).stream().map(XMaterial::name).toList()); + if (autoLoad) + reloadMenuAsync(); + } + + private static List createChoices(Player player, RailTypeDraft draft, RailBlockRole role) { + Map choices = new LinkedHashMap<>(); + if (role == RailBlockRole.RAIL_BLOCK) + for (Material material : List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL)) + choices.put(material, new ItemStack(material)); + for (XMaterial material : role.getSelected(draft)) { + if (material.get() != null) + choices.put(material.get(), new ItemStack(material.get())); + } + for (ItemStack item : player.getInventory().getContents()) { + if (isValidItem(item, role)) + choices.putIfAbsent(item.getType(), new ItemStack(item.getType())); + } + for (ItemStack item : role.createChoices()) + choices.putIfAbsent(item.getType(), item); + return new ArrayList<>(choices.values()); + } + + private static boolean isValidItem(ItemStack item, RailBlockRole role) { + return item != null && !item.getType().isAir() + && (role == RailBlockRole.ICON || item.getType().isBlock()); + } + + @Override + protected void setPreviewItems() { + super.setPreviewItems(); + getMenu().getSlot(CUSTOM_BLOCK_SLOT).setItem(Item.create(Material.HOPPER, "§eUse Your Own Block", + List.of("§7Pick up an item from your inventory,", "§7then click here to select its material.", + "§7Your item is not consumed."))); + getMenu().getSlot(SELECTION_SLOT).setItem(Item.create(Material.PAPER, "§eSelected Materials", + List.of("§f" + String.join(", ", selectedMaterials), + role != RailBlockRole.ICON + ? "§7Click blocks to add/remove them from the palette." + : "§7Choose one material.", "§7Click Next to apply; Back discards changes."))); + getMenu().update(getMenuPlayer()); } @Override @@ -31,16 +89,16 @@ protected void setPaginatedItemClickEventsAsync(List source) { List itemStacks = source.stream().map(l -> (ItemStack) l).toList(); int slot = 0; - // Only one block can be picked per role, so selecting a block deselects the previous one. for (ItemStack ignored : itemStacks) { final int _slot = slot; getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { String type = Item.getUppercaseMaterialString(getMenu().getSlot(_slot).getItem(getMenuPlayer())); if (selectedMaterials.contains(type)) { - selectedMaterials.remove(type); + selectedMaterials.removeIf(type::equals); } else { - selectedMaterials.clear(); + if (role == RailBlockRole.ICON) + selectedMaterials.clear(); selectedMaterials.add(type); } @@ -54,23 +112,46 @@ protected void setPaginatedItemClickEventsAsync(List source) { protected void setItemClickEventsAsync() { super.setItemClickEventsAsync(); - if (canProceed()) - getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - XMaterial material = Item.convertStringToXMaterial(selectedMaterials.getFirst()); + getMenu().getSlot(CUSTOM_BLOCK_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + clickInformation.setResult(Event.Result.DENY); + ItemStack item = clickPlayer.getItemOnCursor(); + if (!isValidItem(item, role)) { + clickPlayer.sendMessage("§cPick up a " + (role == RailBlockRole.ICON ? "non-empty item" : "block") + + " from your inventory first."); + return; + } + String material = XMaterial.matchXMaterial(item.getType()).name(); + if (role == RailBlockRole.ICON) + selectedMaterials.clear(); + if (!selectedMaterials.contains(material)) + selectedMaterials.add(material); + if (choices.stream().noneMatch(choice -> choice.getType() == item.getType())) + choices.add(new ItemStack(item.getType())); + // Finish the cancelled cursor transaction before redrawing the inventory. + Bukkit.getScheduler().runTask(BuildTeamTools.getInstance(), () -> { + if (getMenu().isOpen(clickPlayer)) + reloadMenuAsync(); + }); + }); - if (material == null) - return; + getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (!canProceed()) + return; + List materials = selectedMaterials.stream().map(Item::convertStringToXMaterial).toList(); - role.apply(draft, material); + if (materials.stream().anyMatch(Objects::isNull)) + return; - clickPlayer.closeInventory(); - playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + role.apply(draft, materials); - if (role.isOverheadSetting()) - new RailOverheadWireMenu(clickPlayer, draft, true); - else - new RailTypeEditorMenu(clickPlayer, draft, true); - }); + clickPlayer.closeInventory(); + playSound(clickPlayer, Sound.UI_BUTTON_CLICK); + + if (role.isOverheadSetting()) + new RailOverheadWireMenu(clickPlayer, draft, true); + else + new RailTypeEditorMenu(clickPlayer, draft, true); + }); } private static AbstractMenu createParentMenu(Player player, RailTypeDraft draft, RailBlockRole role) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java index 3903de5f..21bfef50 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java @@ -3,9 +3,11 @@ import com.cryptomorin.xseries.XMaterial; import lombok.Getter; import net.buildtheearth.buildteamtools.utils.MenuItems; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import java.util.List; +import java.util.ArrayList; /** * The block slots of a rail type that can be configured in the editor menu. @@ -16,7 +18,12 @@ enum RailBlockRole { RAIL_BLOCK("Choose a Rail Block") { @Override List createChoices() { - return MenuItems.getBlocksByColor(); + List choices = new ArrayList<>(); + choices.add(new ItemStack(Material.ANVIL)); + choices.add(new ItemStack(Material.CHIPPED_ANVIL)); + choices.add(new ItemStack(Material.DAMAGED_ANVIL)); + choices.addAll(MenuItems.getBlocksByColor()); + return choices; } @Override @@ -33,7 +40,7 @@ List createChoices() { @Override void apply(RailTypeDraft draft, XMaterial material) { - draft.setBlockBelow(material); + draft.setBlocksBelow(List.of(material)); } }, @@ -123,6 +130,30 @@ boolean isOverheadSetting() { abstract void apply(RailTypeDraft draft, XMaterial material); + List getSelected(RailTypeDraft draft) { + return switch (this) { + case BLOCK_BELOW -> draft.getBlocksBelow(); + case RAIL_BLOCK -> draft.getRailBlocks(); + case SLEEPER_BLOCK -> draft.getSleeperBlocks(); + case ICON -> List.of(draft.getIcon()); + case OVERHEAD_POLE_BLOCK -> draft.getOverheadPoleBlocks(); + case OVERHEAD_SUPPORT_BLOCK -> draft.getOverheadSupportBlocks(); + case OVERHEAD_WIRE_BLOCK -> draft.getOverheadWireBlocks(); + }; + } + + void apply(RailTypeDraft draft, List materials) { + switch (this) { + case BLOCK_BELOW -> draft.setBlocksBelow(List.copyOf(materials)); + case RAIL_BLOCK -> draft.setRailBlocks(List.copyOf(materials)); + case SLEEPER_BLOCK -> draft.setSleeperBlocks(List.copyOf(materials)); + case OVERHEAD_POLE_BLOCK -> draft.setOverheadPoleBlocks(List.copyOf(materials)); + case OVERHEAD_SUPPORT_BLOCK -> draft.setOverheadSupportBlocks(List.copyOf(materials)); + case OVERHEAD_WIRE_BLOCK -> draft.setOverheadWireBlocks(List.copyOf(materials)); + case ICON -> apply(draft, materials.getFirst()); + } + } + boolean isOverheadSetting() { return false; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java index 9786f650..ff36bcf4 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailOverheadWireMenu.java @@ -68,15 +68,15 @@ protected void setPreviewItems() { )); getMenu().getSlot(POLE_BLOCK_SLOT).setItem(createBlockItem( "Pole Block", - draft.getOverheadPoleBlock() + draft.getOverheadPoleBlocks() )); getMenu().getSlot(SUPPORT_BLOCK_SLOT).setItem(createBlockItem( "Top Support Block", - draft.getOverheadSupportBlock() + draft.getOverheadSupportBlocks() )); getMenu().getSlot(WIRE_BLOCK_SLOT).setItem(createBlockItem( "Wire Block", - draft.getOverheadWireBlock() + draft.getOverheadWireBlocks() )); getMenu().getSlot(POLE_SPACING_SLOT).setItem(createSpacingItem()); @@ -211,16 +211,16 @@ private ItemStack createToggleItem(String name, boolean enabled, String descript ); } - private ItemStack createBlockItem(String name, XMaterial material) { - Material bukkitMaterial = material.get(); + private ItemStack createBlockItem(String name, List materials) { + Material bukkitMaterial = materials.getFirst().get(); if (bukkitMaterial == null) bukkitMaterial = Objects.requireNonNull(XMaterial.BARRIER.get()); return Item.create( bukkitMaterial, - yellow(name + ": ") + white(RailTypeMenu.formatMaterial(material)), - List.of(gray("Click to choose a different block.")) + yellow(name + ": ") + RailTypeMenu.formatMaterials(materials), + List.of(gray("Click to choose one or more materials.")) ); } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java index 9210408f..f5fd8d0e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraft.java @@ -17,21 +17,21 @@ class RailTypeDraft { private String identifier; private String displayName; private XMaterial icon = XMaterial.RAIL; - private XMaterial railBlock = XMaterial.ANVIL; + private List railBlocks = List.of(XMaterial.ANVIL); private List blocksBelow = List.of(XMaterial.GRAVEL); - private XMaterial sleeperBlock = XMaterial.SPRUCE_PLANKS; + private List sleeperBlocks = List.of(XMaterial.SPRUCE_PLANKS); private int sleeperSpacing = RailType.MIN_SLEEPER_SPACING; private int trackCount = RailType.DEFAULT_TRACK_COUNT; private int trackSpacing = RailType.DEFAULT_TRACK_SPACING; private List trackSpacings = new java.util.ArrayList<>(List.of(RailType.DEFAULT_TRACK_SPACING)); private boolean overheadPolesEnabled = true; - private XMaterial overheadPoleBlock = XMaterial.LIGHT_GRAY_CONCRETE; - private XMaterial overheadSupportBlock = XMaterial.LIGHT_GRAY_CONCRETE; + private List overheadPoleBlocks = List.of(XMaterial.LIGHT_GRAY_CONCRETE); + private List overheadSupportBlocks = List.of(XMaterial.LIGHT_GRAY_CONCRETE); private int overheadPoleSpacing = RailType.DEFAULT_OVERHEAD_POLE_SPACING; private int overheadPoleOffset = RailType.DEFAULT_OVERHEAD_POLE_OFFSET; private int overheadPoleHeight = RailType.DEFAULT_OVERHEAD_POLE_HEIGHT; private boolean overheadWiresEnabled = true; - private XMaterial overheadWireBlock = XMaterial.IRON_BARS; + private List overheadWireBlocks = List.of(XMaterial.IRON_BARS); private boolean trackSwitchesEnabled; static RailTypeDraft from(RailType railType, boolean keepIdentity) { @@ -44,27 +44,21 @@ static RailTypeDraft from(RailType railType, boolean keepIdentity) { } draft.icon = railType.getIcon(); - draft.railBlock = railType.getRailBlock(); + draft.railBlocks = railType.getRailBlocks().isEmpty() ? draft.railBlocks : List.copyOf(railType.getRailBlocks()); draft.blocksBelow = blocksBelow.isEmpty() ? List.of(XMaterial.GRAVEL) : List.copyOf(blocksBelow); - draft.sleeperBlock = railType.getSleeperBlock() == null ? XMaterial.SPRUCE_PLANKS : railType.getSleeperBlock(); + draft.sleeperBlocks = railType.getSleeperBlocks().isEmpty() ? draft.sleeperBlocks : List.copyOf(railType.getSleeperBlocks()); draft.sleeperSpacing = railType.getSleeperSpacing(); draft.trackCount = railType.getTrackCount(); draft.trackSpacing = railType.getTrackSpacing(); draft.trackSpacings = new java.util.ArrayList<>(railType.getTrackSpacings()); draft.overheadPolesEnabled = railType.isOverheadPolesEnabled(); - draft.overheadPoleBlock = railType.getOverheadPoleBlock() == null - ? XMaterial.LIGHT_GRAY_CONCRETE - : railType.getOverheadPoleBlock(); - draft.overheadSupportBlock = railType.getOverheadSupportBlock() == null - ? XMaterial.LIGHT_GRAY_CONCRETE - : railType.getOverheadSupportBlock(); + draft.overheadPoleBlocks = railType.getOverheadPoleBlocks().isEmpty() ? draft.overheadPoleBlocks : List.copyOf(railType.getOverheadPoleBlocks()); + draft.overheadSupportBlocks = railType.getOverheadSupportBlocks().isEmpty() ? draft.overheadSupportBlocks : List.copyOf(railType.getOverheadSupportBlocks()); draft.overheadPoleSpacing = railType.getOverheadPoleSpacing(); draft.overheadPoleOffset = railType.getOverheadPoleOffset(); draft.overheadPoleHeight = railType.getOverheadPoleHeight(); draft.overheadWiresEnabled = railType.isOverheadWiresEnabled(); - draft.overheadWireBlock = railType.getOverheadWireBlock() == null - ? XMaterial.IRON_BARS - : railType.getOverheadWireBlock(); + draft.overheadWireBlocks = railType.getOverheadWireBlocks().isEmpty() ? draft.overheadWireBlocks : List.copyOf(railType.getOverheadWireBlocks()); draft.trackSwitchesEnabled = railType.isTrackSwitchesEnabled(); return draft; } @@ -78,6 +72,46 @@ void setBlockBelow(XMaterial blockBelow) { blocksBelow = List.of(blockBelow); } + XMaterial getRailBlock() { + return railBlocks.getFirst(); + } + + void setRailBlock(XMaterial material) { + railBlocks = List.of(material); + } + + XMaterial getSleeperBlock() { + return sleeperBlocks.getFirst(); + } + + void setSleeperBlock(XMaterial material) { + sleeperBlocks = List.of(material); + } + + XMaterial getOverheadPoleBlock() { + return overheadPoleBlocks.getFirst(); + } + + void setOverheadPoleBlock(XMaterial material) { + overheadPoleBlocks = List.of(material); + } + + XMaterial getOverheadSupportBlock() { + return overheadSupportBlocks.getFirst(); + } + + void setOverheadSupportBlock(XMaterial material) { + overheadSupportBlocks = List.of(material); + } + + XMaterial getOverheadWireBlock() { + return overheadWireBlocks.getFirst(); + } + + void setOverheadWireBlock(XMaterial material) { + overheadWireBlocks = List.of(material); + } + void setTrackCount(int trackCount) { this.trackCount = trackCount; resizeTrackSpacings(); diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java index 605802d8..20e0ea96 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeEditorMenu.java @@ -7,7 +7,6 @@ import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailTypeManager; @@ -100,17 +99,17 @@ protected void setPreviewItems() { getMenu().getSlot(RAIL_BLOCK_SLOT).setItem(createBlockItem( "Rail Block", - draft.getRailBlock(), + draft.getRailBlocks(), "The block the rails are made of." )); getMenu().getSlot(BLOCK_BELOW_SLOT).setItem(createBlockItem( - "Block Below the Rails", - draft.getBlockBelow(), - "Choosing a block replaces the saved ballast mix." + "Blocks Below the Rails", + draft.getBlocksBelow(), + "The material mix placed between and below the rails." )); getMenu().getSlot(SLEEPER_BLOCK_SLOT).setItem(createBlockItem( "Sleeper Block", - draft.getSleeperBlock(), + draft.getSleeperBlocks(), "Sleeper Spacing 0 disables sleepers." )); getMenu().getSlot(ICON_SLOT).setItem(createBlockItem( @@ -242,7 +241,7 @@ private void openNameEditor(Player clickPlayer) { private void saveRailType(Player clickPlayer) { String permission = draft.getIdentifier() == null ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; - if (!Utils.checkPermission(clickPlayer, permission)) + if (!Permissions.checkPermission(clickPlayer, permission)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -253,28 +252,28 @@ private void saveRailType(Player clickPlayer) { RailTypeManager railTypeManager = rail.getRailTypeManager(); String identifier = draft.getIdentifier() == null ? railTypeManager.getNextCustomIdentifier() : draft.getIdentifier(); String displayName = draft.getDisplayName() == null - ? "Custom Rail " + identifier.substring("custom-".length()) + ? railTypeManager.getNextCustomDisplayName() : draft.getDisplayName(); RailType railType = RailType.createCustom(new RailType.Configuration() .identifier(identifier) .displayName(displayName) .icon(draft.getIcon()) - .railBlock(draft.getRailBlock()) + .railBlocks(draft.getRailBlocks()) .blocksBelow(draft.getBlocksBelow()) - .sleeperBlock(draft.getSleeperBlock()) + .sleeperBlocks(draft.getSleeperBlocks()) .sleeperSpacing(draft.getSleeperSpacing()) .trackCount(draft.getTrackCount()) .trackSpacing(draft.getTrackSpacing()) .trackSpacings(draft.getTrackSpacings()) .overheadPolesEnabled(draft.isOverheadPolesEnabled()) - .overheadPoleBlock(draft.getOverheadPoleBlock()) - .overheadSupportBlock(draft.getOverheadSupportBlock()) + .overheadPoleBlocks(draft.getOverheadPoleBlocks()) + .overheadSupportBlocks(draft.getOverheadSupportBlocks()) .overheadPoleSpacing(draft.getOverheadPoleSpacing()) .overheadPoleOffset(draft.getOverheadPoleOffset()) .overheadPoleHeight(draft.getOverheadPoleHeight()) .overheadWiresEnabled(draft.isOverheadWiresEnabled()) - .overheadWireBlock(draft.getOverheadWireBlock()) + .overheadWireBlocks(draft.getOverheadWireBlocks()) .trackSwitchesEnabled(draft.isTrackSwitchesEnabled())); String error = railTypeManager.saveRailType(railType); @@ -334,6 +333,15 @@ private void setCounterClickEvents(int slot, int minValue, int maxValue, IntSupp }); } + private ItemStack createBlockItem(String name, List materials, String description) { + List lore = new java.util.ArrayList<>(); + lore.add(gray(description)); + for (XMaterial material : materials) + lore.add(white(RailTypeMenu.formatMaterial(material))); + lore.add(gray("Click to choose one or more materials.")); + Material icon = materials.getFirst().get(); + return Item.create(icon == null ? Material.BARRIER : icon, yellow(name), lore); + } private ItemStack createBlockItem(String name, XMaterial material, String description) { Material bukkitMaterial = material.get(); diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java index ea196496..7dea9c8e 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenu.java @@ -7,7 +7,6 @@ import net.buildtheearth.buildteamtools.modules.generator.GeneratorModule; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailFlag; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import net.buildtheearth.buildteamtools.modules.generator.menu.GeneratorMenu; @@ -52,7 +51,7 @@ public RailTypeMenu(Player player, boolean autoLoad) { } private RailTypeMenu(Player player, String searchQuery, Collection selectedForDeletion, boolean autoLoad) { - super(player, RAIL_TYPE_INV_NAME, getRailTypes(searchQuery), new GeneratorMenu(player, false), autoLoad); + super(player, RAIL_TYPE_INV_NAME, getRailTypes(player, searchQuery), new GeneratorMenu(player, false), autoLoad); this.searchQuery = searchQuery; this.selectedForDeletion = new LinkedHashSet<>(selectedForDeletion); preselectCurrentRailType(player); @@ -77,7 +76,7 @@ else if (value instanceof String identifier && RailType.byString(identifier) != selectedNames.add(identifier); } - private static @NonNull List> getRailTypes(String searchQuery) { + private static @NonNull List> getRailTypes(Player player, String searchQuery) { List> railTypes = new ArrayList<>(); Rail rail = GeneratorModule.getInstance().getRail(); @@ -92,7 +91,7 @@ else if (value instanceof String identifier && RailType.byString(identifier) != && !railType.getDisplayName().toLowerCase(Locale.ROOT).contains(normalizedQuery)) continue; - railTypes.add(new MutablePair<>(RailTypeMenuItems.createRailTypeItem(railType), railType.getIdentifier())); + railTypes.add(new MutablePair<>(RailTypeMenuItems.createRailTypeItem(railType, player), railType.getIdentifier())); } return railTypes; @@ -122,32 +121,35 @@ protected void setPreviewItems() { ) )); - getMenu().getSlot(CREATE_ITEM_SLOT).setItem(Item.create( - Objects.requireNonNull(XMaterial.NETHER_STAR.get()), - green("Create a Rail Type"), - List.of(gray("Configure and save a custom rail type.")) - )); - - getMenu().getSlot(RELOAD_ITEM_SLOT).setItem(Item.create( - Objects.requireNonNull(XMaterial.CLOCK.get()), - yellow("Reload Rail Types"), - List.of( - gray("Re-reads rail-types.yml from disk,"), - gray("so you can test changes without a restart.") - ) - )); - - getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setItem(Item.create( - Objects.requireNonNull((selectedForDeletion.isEmpty() ? XMaterial.GRAY_DYE : XMaterial.RED_DYE).get()), - selectedForDeletion.isEmpty() ? gray("Bulk Delete") : red("Delete Selected Rail Types"), - List.of( - gray("Selected: ") + white(String.valueOf(selectedForDeletion.size())), - gray("Shift+Left-Click custom types to select them."), - selectedForDeletion.isEmpty() - ? darkGray("No custom rail types selected.") - : red("Click to permanently delete all selected types.") - ) - )); + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_CREATE)) + getMenu().getSlot(CREATE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.NETHER_STAR.get()), + green("Create a Rail Type"), + List.of(gray("Configure and save a custom rail type.")) + )); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_EDIT)) + getMenu().getSlot(RELOAD_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull(XMaterial.CLOCK.get()), + yellow("Reload Rail Types"), + List.of( + gray("Re-reads rail-types.yml from disk,"), + gray("so you can test changes without a restart.") + ) + )); + + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_DELETE)) + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setItem(Item.create( + Objects.requireNonNull((selectedForDeletion.isEmpty() ? XMaterial.GRAY_DYE : XMaterial.RED_DYE).get()), + selectedForDeletion.isEmpty() ? gray("Bulk Delete") : red("Delete Selected Rail Types"), + List.of( + gray("Selected: ") + white(String.valueOf(selectedForDeletion.size())), + gray("Shift+Left-Click custom types to select them."), + selectedForDeletion.isEmpty() + ? darkGray("No custom rail types selected.") + : red("Click to permanently delete all selected types.") + ) + )); } @Override @@ -157,12 +159,15 @@ protected void setPaginatedPreviewItems(@NonNull List source) { // Render the selection glow on a copy so deselected items lose their glow again. for (MutablePair item : pagItems) { - ItemStack displayedItem = item.getLeft(); + RailType currentType = RailType.byString(item.getRight()); + ItemStack displayedItem = currentType == null ? item.getLeft() + : RailTypeMenuItems.createRailTypeItem(currentType, getMenuPlayer()); if (selectedNames.contains(item.getRight())) displayedItem = RailTypeMenuItems.addSelectionGlow(displayedItem); - if (selectedForDeletion.contains(item.getRight())) + if (getMenuPlayer().hasPermission(Permissions.RAIL_TYPE_DELETE) + && selectedForDeletion.contains(item.getRight())) displayedItem = RailTypeMenuItems.addDeletionMarker(displayedItem); getMenu().getSlot(slot).setItem(displayedItem); @@ -180,19 +185,32 @@ protected void setPaginatedItemClickEventsAsync(@NonNull List source) { final int _slot = slot; getMenu().getSlot(_slot).setClickHandler((clickPlayer, clickInformation) -> { String type = item.getRight().toLowerCase(); + RailType railType = RailType.byString(type); + if (railType == null) + return; - if (clickInformation.getClickType() == ClickType.SHIFT_LEFT) { + if (clickInformation.getClickType() == ClickType.SHIFT_LEFT + && !railType.isBuiltIn() && clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) { toggleBulkDeleteSelection(clickPlayer, type); return; } - if (clickInformation.getClickType() == ClickType.SHIFT_RIGHT) { + if (clickInformation.getClickType() == ClickType.SHIFT_RIGHT + && !railType.isBuiltIn() && clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) { deleteCustomRailType(clickPlayer, type); return; } - if (clickInformation.getClickType() == ClickType.RIGHT) { - openEditorForRailType(clickPlayer, type); + if (clickInformation.getClickType() == ClickType.DROP + && clickPlayer.hasPermission(Permissions.RAIL_TYPE_CREATE)) { + openEditorForRailType(clickPlayer, type, true); + return; + } + + if (clickInformation.getClickType() == ClickType.RIGHT + && clickPlayer.hasPermission(railType.isBuiltIn() + ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT)) { + openEditorForRailType(clickPlayer, type, railType.isBuiltIn()); return; } @@ -244,7 +262,7 @@ private String getRailTypeDisplayName(String identifier) { return railType == null ? identifier : railType.getDisplayName(); } - private void openEditorForRailType(Player clickPlayer, String identifier) { + private void openEditorForRailType(Player clickPlayer, String identifier, boolean copy) { Rail rail = GeneratorModule.getInstance().getRail(); if (rail == null) @@ -257,27 +275,27 @@ private void openEditorForRailType(Player clickPlayer, String identifier) { return; } - String permission = railType.isBuiltIn() ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; + String permission = copy ? Permissions.RAIL_TYPE_CREATE : Permissions.RAIL_TYPE_EDIT; - if (!Utils.checkPermission(clickPlayer, permission)) + if (!Permissions.checkPermission(clickPlayer, permission)) return; clickPlayer.closeInventory(); playSound(clickPlayer, Sound.UI_BUTTON_CLICK); clickPlayer.sendMessage(ChatHelper.getStandardComponent( true, - railType.isBuiltIn() + copy ? "Opening an editable copy of rail type '%s'." : "Editing rail type '%s'.", railType.getIdentifier() )); // Built-in types cannot be overwritten, so editing one saves an editable copy instead. - new RailTypeEditorMenu(clickPlayer, RailTypeDraft.from(railType, !railType.isBuiltIn()), true); + new RailTypeEditorMenu(clickPlayer, RailTypeDraft.from(railType, !copy), true); } private void deleteCustomRailType(Player clickPlayer, String identifier) { - if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + if (!Permissions.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -319,6 +337,9 @@ private void deleteCustomRailType(Player clickPlayer, String identifier) { } private void toggleBulkDeleteSelection(Player clickPlayer, String identifier) { + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) + return; + Rail rail = GeneratorModule.getInstance().getRail(); if (rail == null) @@ -353,7 +374,7 @@ private void deleteSelectedRailTypes(Player clickPlayer) { return; } - if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) + if (!Permissions.checkPermission(clickPlayer, Permissions.RAIL_TYPE_DELETE)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -394,7 +415,7 @@ protected void setItemClickEventsAsync() { }); getMenu().getSlot(CREATE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_CREATE)) + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_CREATE)) return; clickPlayer.closeInventory(); @@ -405,7 +426,7 @@ protected void setItemClickEventsAsync() { }); getMenu().getSlot(RELOAD_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { - if (!Utils.checkPermission(clickPlayer, Permissions.RAIL_TYPE_EDIT)) + if (!clickPlayer.hasPermission(Permissions.RAIL_TYPE_EDIT)) return; Rail rail = GeneratorModule.getInstance().getRail(); @@ -425,8 +446,10 @@ protected void setItemClickEventsAsync() { new RailTypeMenu(clickPlayer, searchQuery, selectedForDeletion, true); }); - getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> - deleteSelectedRailTypes(clickPlayer)); + getMenu().getSlot(BULK_DELETE_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> { + if (clickPlayer.hasPermission(Permissions.RAIL_TYPE_DELETE)) + deleteSelectedRailTypes(clickPlayer); + }); if (canProceed()) getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java index 699f15a0..38639d80 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeMenuItems.java @@ -6,6 +6,8 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; +import org.bukkit.entity.Player; +import net.buildtheearth.buildteamtools.modules.network.model.Permissions; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; @@ -23,7 +25,7 @@ final class RailTypeMenuItems { private RailTypeMenuItems() { } - static ItemStack createRailTypeItem(RailType railType) { + static ItemStack createRailTypeItem(RailType railType, Player player) { Material icon = railType.getIcon().get(); if (icon == null) @@ -31,10 +33,15 @@ static ItemStack createRailTypeItem(RailType railType) { List lore = createConfigurationLore(railType); - if (railType.isBuiltIn()) + lore.add(darkGray("Left-Click to select")); + if (railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_CREATE)) lore.add(darkGray("Right-Click to create an editable copy")); - else { - lore.add(darkGray("Right-Click to edit ") + gray("- ") + darkGray("Shift+Right-Click to delete")); + if (!railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_EDIT)) + lore.add(darkGray("Right-Click to edit")); + if (player.hasPermission(Permissions.RAIL_TYPE_CREATE)) + lore.add(darkGray("Press your drop key (Q) to create an editable copy")); + if (!railType.isBuiltIn() && player.hasPermission(Permissions.RAIL_TYPE_DELETE)) { + lore.add(darkGray("Shift+Right-Click to delete")); lore.add(darkGray("Shift+Left-Click to select for bulk deletion")); } @@ -43,7 +50,7 @@ static ItemStack createRailTypeItem(RailType railType) { private static List createConfigurationLore(RailType railType) { List lore = new ArrayList<>(); - lore.add(gray("Rail Block: ") + white(formatMaterial(railType.getRailBlock()))); + lore.add(gray("Rail Block: ") + formatMaterials(railType.getRailBlocks())); lore.add(gray("Blocks Below: ") + formatMaterials(railType.getBlocksBelow())); addSleeperLore(lore, railType); addTrackLore(lore, railType); @@ -61,7 +68,7 @@ private static void addSleeperLore(List lore, RailType railType) { } lore.add(gray("Sleepers: ") - + white(formatMaterial(railType.getSleeperBlock())) + + formatMaterials(railType.getSleeperBlocks()) + gray(" every ") + white(String.valueOf(railType.getSleeperSpacing())) + gray(" blocks")); @@ -85,14 +92,16 @@ private static void addOverheadLore(List lore, RailType railType) { : RailMenuText.DISABLED)); if (railType.hasOverheadPoles()) { - lore.add(gray("Pole Block: ") + white(formatMaterial(railType.getOverheadPoleBlock()))); - lore.add(gray("Top Support: ") + white(formatMaterial(railType.getOverheadSupportBlock()))); + lore.add(gray("Pole Block: ") + formatMaterials(railType.getOverheadPoleBlocks())); + lore.add(gray("Top Support: ") + formatMaterials(railType.getOverheadSupportBlocks())); lore.add(gray("Pole Spacing: ") + white(String.valueOf(railType.getOverheadPoleSpacing()))); } lore.add(gray("Overhead Wires: ") + white(railType.hasOverheadWires() ? RailMenuText.ENABLED : RailMenuText.DISABLED)); + if (railType.hasOverheadWires()) + lore.add(gray("Wire Blocks: ") + formatMaterials(railType.getOverheadWireBlocks())); } static ItemStack addSelectionGlow(ItemStack item) { diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java index e20b6287..7e0c520d 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/menu/GeneratorMenu.java @@ -10,7 +10,6 @@ import net.buildtheearth.buildteamtools.modules.generator.components.house.RoofType; import net.buildtheearth.buildteamtools.modules.generator.components.house.menu.WallColorMenu; import net.buildtheearth.buildteamtools.modules.generator.components.rail.Rail; -import net.buildtheearth.buildteamtools.utils.Utils; import net.buildtheearth.buildteamtools.modules.generator.components.rail.RailSettings; import net.buildtheearth.buildteamtools.modules.generator.components.rail.menu.RailTypeMenu; import net.buildtheearth.buildteamtools.modules.generator.components.road.Road; @@ -243,7 +242,7 @@ private void handleRoadClick(Player player, ClickType clickType) { private void handleRailClick(Player player, ClickType clickType) { if (showTutorialForRightClick(player, clickType, GeneratorType.RAIL) - || !Utils.checkPermission(player, Permissions.RAIL_TYPE_MENU)) + || !Permissions.checkPermission(player, Permissions.RAIL_TYPE_MENU)) return; Rail rail = GeneratorModule.getInstance().getRail(); diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java index b9e2751f..a797ebda 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/navigation/components/warps/commands/WarpCommand.java @@ -109,7 +109,7 @@ private boolean handleMigrateCommand(@NonNull Player player, String @NonNull [] private boolean handleRandomWarpCommand(@NonNull Player player, String @NonNull [] args) { if (!player.hasPermission(Permissions.WARP_RANDOM)) { - Utils.sendNoPermissionMessage(player, Permissions.WARP_RANDOM); + Permissions.sendNoPermissionMessage(player, Permissions.WARP_RANDOM); return true; } diff --git a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java index a32995be..a791818f 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java +++ b/src/main/java/net/buildtheearth/buildteamtools/modules/network/model/Permissions.java @@ -1,6 +1,8 @@ package net.buildtheearth.buildteamtools.modules.network.model; import lombok.experimental.UtilityClass; +import org.bukkit.command.CommandSender; +import com.alpsbte.alpslib.utils.ChatHelper; @UtilityClass public class Permissions { @@ -44,4 +46,17 @@ public class Permissions { public static final String AUTO_TPLL = "btt.global.autotpll"; + /** Checks permission and sends the standard denial message when access is denied. */ + public static boolean checkPermission(CommandSender sender, String permission) { + if (sender.hasPermission(permission)) + return true; + + sendNoPermissionMessage(sender, permission); + return false; + } + + public static void sendNoPermissionMessage(CommandSender sender, String permission) { + sender.sendMessage(ChatHelper.getErrorComponent("You don't have permission to execute this command. Required " + + "permission: " + permission)); + } } diff --git a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java index 37c5c6bc..a4cab0e8 100644 --- a/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java +++ b/src/main/java/net/buildtheearth/buildteamtools/utils/Utils.java @@ -1,7 +1,5 @@ package net.buildtheearth.buildteamtools.utils; -import com.alpsbte.alpslib.utils.ChatHelper; -import org.bukkit.command.CommandSender; import java.util.ArrayList; import java.util.List; @@ -133,17 +131,4 @@ public static List getTabCompleterArgs(String[] args, String parentArg, return null; } - /** Checks permission and sends the standard denial message when access is denied. */ - public static boolean checkPermission(CommandSender sender, String permission) { - if (sender.hasPermission(permission)) - return true; - - sendNoPermissionMessage(sender, permission); - return false; - } - - public static void sendNoPermissionMessage(CommandSender sender, String permission) { - sender.sendMessage(ChatHelper.getErrorComponent("You don't have permission to execute this command. Required " + - "permission: " + permission)); - } } diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java new file mode 100644 index 00000000..0011982c --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailPaletteStorageTest.java @@ -0,0 +1,55 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import com.cryptomorin.xseries.XMaterial; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RailPaletteStorageTest { + @Test + void saveValidationReceivesAllPalettesWithoutCollapsingToTheirFirstMaterial() { + List mix = List.of(XMaterial.STONE, XMaterial.COBBLESTONE); + RailType original = RailType.createCustom(new RailType.Configuration() + .railBlocks(mix).sleeperBlocks(mix).overheadPoleBlocks(mix) + .overheadSupportBlocks(mix).overheadWireBlocks(mix)); + RailType.Configuration saved = RailTypeManager.toConfiguration(original); + assertEquals(mix, saved.railBlocks()); + assertEquals(mix, saved.sleeperBlocks()); + assertEquals(mix, saved.overheadPoleBlocks()); + assertEquals(mix, saved.overheadSupportBlocks()); + assertEquals(mix, saved.overheadWireBlocks()); + } + + @Test + void acceptsLegacyScalarAndPreservesPaletteOrderAndWeights() { + assertEquals(List.of(XMaterial.ANVIL), RailTypeManager.parsePalette("ANVIL")); + assertEquals(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL, XMaterial.ANVIL), + RailTypeManager.parsePalette(List.of("ANVIL", "CHIPPED_ANVIL", "ANVIL"))); + assertEquals(List.of(), RailTypeManager.parsePalette(null)); + assertNull(RailTypeManager.parsePalette(List.of("NOT_A_BLOCK")).getFirst()); + assertNull(RailTypeManager.parsePalette(List.of(123)).getFirst()); + } + + @Test + void migrationConvertsEveryScalarAndPreservesExistingMixes() throws Exception { + YamlConfiguration config = new YamlConfiguration(); + List paths = List.of("rail-block", "sleeper-block", "overhead.poles.block", + "overhead.support.block", "overhead.wires.block"); + for (String path : paths) + config.set("rail-types.example." + path, "STONE"); + config.set("rail-types.example.blocks-below", List.of("GRAVEL", "STONE", "GRAVEL")); + config.set("rail-types.mixed.rail-block", List.of("ANVIL", "CHIPPED_ANVIL")); + + RailTypeManager.migrateVersionFourToFive(config); + RailTypeManager.migrateVersionFourToFive(config); + YamlConfiguration reloaded = new YamlConfiguration(); + reloaded.loadFromString(config.saveToString()); + for (String path : paths) + assertEquals(List.of(XMaterial.STONE), RailTypeManager.parsePalette(reloaded.get("rail-types.example." + path))); + assertEquals(List.of("GRAVEL", "STONE", "GRAVEL"), reloaded.getStringList("rail-types.example.blocks-below")); + assertEquals(List.of("ANVIL", "CHIPPED_ANVIL"), reloaded.getStringList("rail-types.mixed.rail-block")); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java new file mode 100644 index 00000000..56986cac --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeNamingTest.java @@ -0,0 +1,22 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RailTypeNamingTest { + @Test + void renamedFirstCopyFreesItsDisplayName() { + assertEquals("Custom Rail 2", RailTypeManager.getNextCustomDisplayName(List.of("Standard", "Custom Rail 1"))); + assertEquals("Custom Rail 1", RailTypeManager.getNextCustomDisplayName(List.of("Standard", "Regional Railway"))); + } + + @Test + void reusesLowestGapWithoutCaseInsensitiveCollisions() { + assertEquals("Custom Rail 2", RailTypeManager.getNextCustomDisplayName( + List.of("CUSTOM RAIL 1", "Custom Rail 3", "Custom Rail 4"))); + assertEquals("Custom Rail 1", RailTypeManager.getNextCustomDisplayName(List.of())); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java new file mode 100644 index 00000000..eeacb98e --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailMaterialPaletteTest.java @@ -0,0 +1,35 @@ +package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class RailMaterialPaletteTest { + @Test + void eachAxisUsesTheWholeMixAndRepeatedPositionsStayStable() { + List palette = List.of("a", "b", "c"); + for (int axis = 0; axis < 3; axis++) { + Set seen = new HashSet<>(); + for (int coordinate = -128; coordinate <= 128; coordinate++) { + int x = axis == 0 ? coordinate : 0; + int y = axis == 1 ? coordinate : 0; + int z = axis == 2 ? coordinate : 0; + String selected = RailMaterialPalette.select(palette, x, y, z); + assertEquals(selected, RailMaterialPalette.select(palette, x, y, z)); + seen.add(selected); + } + assertEquals(Set.copyOf(palette), seen); + } + } + + @Test + void handlesSingleMaterialExtremeCoordinatesAndEmptyPalette() { + assertEquals("only", RailMaterialPalette.select(List.of("only"), Integer.MIN_VALUE, 0, Integer.MAX_VALUE)); + assertTrue(List.of("a", "b").contains(RailMaterialPalette.select(List.of("a", "b"), Integer.MIN_VALUE, Integer.MAX_VALUE, -1))); + assertThrows(IllegalArgumentException.class, () -> RailMaterialPalette.select(List.of(), 0, 0, 0)); + } +} diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java index 723e59af..ede9f8c0 100644 --- a/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailTypeDraftTest.java @@ -4,12 +4,89 @@ import net.buildtheearth.buildteamtools.modules.generator.components.rail.configuration.RailType; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; class RailTypeDraftTest { + @Test + void everyBuildingRoleSupportsAnIndependentPaletteWhileIconStaysSingle() { + List palette = List.of(XMaterial.STONE, XMaterial.COBBLESTONE); + RailType source = RailType.createCustom(new RailType.Configuration() + .railBlocks(palette).sleeperBlocks(palette).overheadPoleBlocks(palette) + .overheadSupportBlocks(palette).overheadWireBlocks(palette).blocksBelow(palette) + .icon(XMaterial.RAIL).trackCount(1).trackSpacing(5)); + RailTypeDraft draft = RailTypeDraft.from(source, false); + for (RailBlockRole role : RailBlockRole.values()) { + if (role == RailBlockRole.ICON) + continue; + assertEquals(palette, role.getSelected(draft)); + List selection = new ArrayList<>(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL)); + role.apply(draft, selection); + selection.clear(); + assertEquals(List.of(XMaterial.ANVIL, XMaterial.CHIPPED_ANVIL), role.getSelected(draft)); + } + assertEquals(List.of(XMaterial.RAIL), RailBlockRole.ICON.getSelected(draft)); + assertEquals(palette, source.getRailBlocks()); + assertEquals(palette, source.getSleeperBlocks()); + assertEquals(palette, source.getOverheadPoleBlocks()); + assertEquals(palette, source.getOverheadSupportBlocks()); + assertEquals(palette, source.getOverheadWireBlocks()); + } + + private RailType source() { + return RailType.createCustom(new RailType.Configuration() + .identifier("custom-1").displayName("Regional Railway") + .icon(XMaterial.RAIL).railBlock(XMaterial.ANVIL) + .blocksBelow(List.of(XMaterial.DEAD_FIRE_CORAL_BLOCK, XMaterial.STONE, XMaterial.COBBLESTONE)) + .sleeperBlock(XMaterial.SPRUCE_PLANKS).sleeperSpacing(4) + .trackCount(3).trackSpacing(5).trackSpacings(List.of(5, 8)) + .overheadPolesEnabled(true).overheadPoleBlock(XMaterial.LIGHT_GRAY_CONCRETE) + .overheadSupportBlock(XMaterial.STONE).overheadPoleSpacing(20) + .overheadPoleOffset(4).overheadPoleHeight(7) + .overheadWiresEnabled(true).overheadWireBlock(XMaterial.IRON_BARS) + .trackSwitchesEnabled(true)); + } + + @Test + void copyingCustomTypePreservesConfigurationWithoutReusingIdentity() { + RailType source = source(); + RailTypeDraft copy = RailTypeDraft.from(source, false); + assertNull(copy.getIdentifier()); + assertNull(copy.getDisplayName()); + assertEquals(source.getBlocksBelow(), copy.getBlocksBelow()); + assertEquals(source.getRailBlock(), copy.getRailBlock()); + assertEquals(source.getTrackSpacings(), copy.getTrackSpacings()); + assertEquals(source.getSleeperSpacing(), copy.getSleeperSpacing()); + assertEquals(source.getOverheadPoleSpacing(), copy.getOverheadPoleSpacing()); + assertEquals(source.getOverheadSupportBlock(), copy.getOverheadSupportBlock()); + assertEquals(source.isTrackSwitchesEnabled(), copy.isTrackSwitchesEnabled()); + + copy.setTrackSpacing(0, 9); + assertEquals(List.of(5, 8), source.getTrackSpacings()); + } + + @Test + void editingPreservesIdentityAndEntirePalette() { + RailType source = source(); + RailTypeDraft edit = RailTypeDraft.from(source, true); + assertEquals(source.getIdentifier(), edit.getIdentifier()); + assertEquals(source.getDisplayName(), edit.getDisplayName()); + assertEquals(source.getBlocksBelow(), RailBlockRole.BLOCK_BELOW.getSelected(edit)); + RailBlockRole.RAIL_BLOCK.apply(edit, List.of(XMaterial.CHIPPED_ANVIL)); + assertEquals(XMaterial.CHIPPED_ANVIL, edit.getRailBlock()); + assertEquals(source.getBlocksBelow(), edit.getBlocksBelow()); + } + + @Test + void applyingPaletteKeepsAllMaterialsAndIsolatesSelectionChanges() { + RailTypeDraft draft = RailTypeDraft.from(source(), true); + List selection = new ArrayList<>(List.of(XMaterial.GRAVEL, XMaterial.STONE)); + RailBlockRole.BLOCK_BELOW.apply(draft, selection); + selection.clear(); + assertEquals(List.of(XMaterial.GRAVEL, XMaterial.STONE), draft.getBlocksBelow()); + } private RailType mixedType() { return RailType.createCustom(new RailType.Configuration() diff --git a/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java b/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java new file mode 100644 index 00000000..f151fc87 --- /dev/null +++ b/src/test/java/net/buildtheearth/buildteamtools/modules/network/model/PermissionsTest.java @@ -0,0 +1,41 @@ +package net.buildtheearth.buildteamtools.modules.network.model; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PermissionsTest { + private CommandSender sender(boolean allowed, List messages) { + return (CommandSender) Proxy.newProxyInstance(CommandSender.class.getClassLoader(), + new Class[]{CommandSender.class}, (proxy, method, args) -> { + if (method.getName().equals("hasPermission")) + return allowed; + if (method.getName().equals("sendMessage")) { + for (Object arg : args) + if (arg instanceof Component component) + messages.add(component); + return null; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + + @Test + void permissionCheckUsesTheExistingDenialMessageAndAcceptsAnyCommandSender() { + List messages = new ArrayList<>(); + assertTrue(Permissions.checkPermission(sender(true, messages), Permissions.RAIL_TYPE_EDIT)); + assertTrue(messages.isEmpty()); + assertFalse(Permissions.checkPermission(sender(false, messages), Permissions.RAIL_TYPE_EDIT)); + assertEquals(1, messages.size()); + String denial = PlainTextComponentSerializer.plainText().serialize(messages.getFirst()); + assertTrue(denial.contains(Permissions.RAIL_TYPE_EDIT)); + assertTrue(denial.contains("You don't have permission to execute this command.")); + } +}