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 83809e8c..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
@@ -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 (!Permissions.checkPermission(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/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..ed03ad0d
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailType.java
@@ -0,0 +1,583 @@
+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 List railBlocks;
+ private final List blocksBelow;
+ 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 List overheadPoleBlocks;
+ private final List overheadSupportBlocks;
+ private final int overheadPoleSpacing;
+ private final int overheadPoleOffset;
+ private final int overheadPoleHeight;
+ private final boolean overheadWiresEnabled;
+ private final List overheadWireBlocks;
+ 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.railBlocks = List.copyOf(configuration.railBlocks());
+ this.blocksBelow = configuredBlocksBelow == null ? List.of() : List.copyOf(configuredBlocksBelow);
+ this.sleeperBlocks = List.copyOf(configuration.sleeperBlocks());
+ 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.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.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 && !sleeperBlocks.isEmpty();
+ }
+
+ public boolean hasOverheadPoles() {
+ return overheadPolesEnabled && !overheadPoleBlocks.isEmpty();
+ }
+
+ public boolean hasOverheadWires() {
+ return overheadWiresEnabled && !overheadWireBlocks.isEmpty();
+ }
+
+ /**
+ * 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 (!isPlaceablePalette(configuration.railBlocks()))
+ 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 || !configuration.sleeperBlocks().isEmpty())
+ && !isPlaceablePalette(configuration.sleeperBlocks()))
+ 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() || !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() || !configuration.overheadPoleBlocks().isEmpty())
+ && !isPlaceablePalette(configuration.overheadPoleBlocks()))
+ return "Overhead pole block must be a valid placeable Minecraft block.";
+
+ if ((configuration.overheadPolesEnabled() || !configuration.overheadSupportBlocks().isEmpty())
+ && !isPlaceablePalette(configuration.overheadSupportBlocks()))
+ return "Overhead support block must be a valid placeable Minecraft block.";
+
+ return configuration.overheadPolesEnabled() ? validateOverheadPoleMeasurements(configuration) : null;
+ }
+
+ 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 isPlaceablePalette(List materials) {
+ return !materials.isEmpty() && materials.stream().allMatch(RailType::isPlaceableBlock);
+ }
+
+ 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 List railBlocks = List.of();
+ private @Nullable List blocksBelow;
+ private List sleeperBlocks = List.of();
+ private int sleeperSpacing;
+ private int trackCount;
+ private int trackSpacing;
+ private @Nullable List trackSpacings;
+ private boolean overheadPolesEnabled;
+ 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 List overheadWireBlocks = List.of();
+ 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 List railBlocks() {
+ return railBlocks;
+ }
+
+ public Configuration railBlocks(List materials) {
+ this.railBlocks = new java.util.ArrayList<>(materials);
+ return this;
+ }
+
+ public @Nullable XMaterial railBlock() {
+ return railBlocks.isEmpty() ? null : railBlocks.getFirst();
+ }
+
+ public Configuration railBlock(@Nullable XMaterial railBlock) {
+ this.railBlocks = railBlock == null ? List.of() : List.of(railBlock);
+ return this;
+ }
+
+ public @Nullable List blocksBelow() {
+ return blocksBelow;
+ }
+
+ public Configuration blocksBelow(@Nullable List blocksBelow) {
+ this.blocksBelow = 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 sleeperBlocks.isEmpty() ? null : sleeperBlocks.getFirst();
+ }
+
+ public Configuration sleeperBlock(@Nullable XMaterial sleeperBlock) {
+ this.sleeperBlocks = sleeperBlock == null ? List.of() : List.of(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 List overheadPoleBlocks() {
+ return overheadPoleBlocks;
+ }
+
+ public Configuration overheadPoleBlocks(List materials) {
+ this.overheadPoleBlocks = new java.util.ArrayList<>(materials);
+ return this;
+ }
+
+ public @Nullable XMaterial overheadPoleBlock() {
+ return overheadPoleBlocks.isEmpty() ? null : overheadPoleBlocks.getFirst();
+ }
+
+ public Configuration overheadPoleBlock(@Nullable XMaterial 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 overheadSupportBlocks.isEmpty() ? null : overheadSupportBlocks.getFirst();
+ }
+
+ public Configuration overheadSupportBlock(@Nullable XMaterial overheadSupportBlock) {
+ this.overheadSupportBlocks = overheadSupportBlock == null ? List.of() : List.of(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 List overheadWireBlocks() {
+ return overheadWireBlocks;
+ }
+
+ public Configuration overheadWireBlocks(List materials) {
+ this.overheadWireBlocks = new java.util.ArrayList<>(materials);
+ return this;
+ }
+
+ public @Nullable XMaterial overheadWireBlock() {
+ return overheadWireBlocks.isEmpty() ? null : overheadWireBlocks.getFirst();
+ }
+
+ public Configuration overheadWireBlock(@Nullable XMaterial overheadWireBlock) {
+ this.overheadWireBlocks = overheadWireBlock == null ? List.of() : List.of(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..f92ee5c6
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/configuration/RailTypeManager.java
@@ -0,0 +1,515 @@
+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.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.
+ * 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 = 5;
+ 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;
+ }
+
+ /** 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;
+
+ 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);
+ List railBlocks = parsePalette(section.get(KEY_RAIL_BLOCK));
+ List blocksBelow = parseMaterials(section.getStringList(KEY_BLOCKS_BELOW));
+ 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);
+ 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);
+ 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
+ );
+ 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);
+ 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(railBlocks.isEmpty() ? null : railBlocks.getFirst())
+ .railBlocks(railBlocks)
+ .blocksBelow(blocksBelow)
+ .sleeperBlocks(sleeperBlocks)
+ .sleeperSpacing(sleeperSpacing)
+ .trackCount(trackCount)
+ .trackSpacing(trackSpacing)
+ .trackSpacings(trackSpacings)
+ .overheadPolesEnabled(overheadPolesEnabled)
+ .overheadPoleBlocks(overheadPoleBlocks)
+ .overheadSupportBlocks(overheadSupportBlocks)
+ .overheadPoleSpacing(overheadPoleSpacing)
+ .overheadPoleOffset(overheadPoleOffset)
+ .overheadPoleHeight(overheadPoleHeight)
+ .overheadWiresEnabled(overheadWiresEnabled)
+ .overheadWireBlocks(overheadWireBlocks)
+ .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 ? railBlocks.getFirst() : icon)
+ .railBlocks(railBlocks)
+ .blocksBelow(blocksBelow)
+ .sleeperBlocks(sleeperBlocks)
+ .sleeperSpacing(sleeperSpacing)
+ .trackCount(trackCount)
+ .trackSpacing(trackSpacing)
+ .trackSpacings(trackSpacings)
+ .overheadPolesEnabled(overheadPolesEnabled)
+ .overheadPoleBlocks(overheadPoleBlocks)
+ .overheadSupportBlocks(overheadSupportBlocks)
+ .overheadPoleSpacing(overheadPoleSpacing)
+ .overheadPoleOffset(overheadPoleOffset)
+ .overheadPoleHeight(overheadPoleHeight)
+ .overheadWiresEnabled(overheadWiresEnabled)
+ .overheadWireBlocks(overheadWireBlocks)
+ .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.getRailBlocks().stream().map(XMaterial::name).toList());
+ config.set(path + "." + KEY_BLOCKS_BELOW, railType.getBlocksBelow().stream().map(XMaterial::name).toList());
+
+ 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());
+ 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());
+
+ config.set(path + "." + KEY_OVERHEAD_POLE_BLOCK, railType.getOverheadPoleBlocks().stream().map(XMaterial::name).toList());
+
+ config.set(path + "." + KEY_OVERHEAD_SUPPORT_BLOCK, railType.getOverheadSupportBlocks().stream().map(XMaterial::name).toList());
+
+ config.set(path + "." + KEY_OVERHEAD_WIRE_BLOCK, railType.getOverheadWireBlocks().stream().map(XMaterial::name).toList());
+ }
+
+ 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);
+ migrateVersionFourToFive(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));
+ }
+ }
+
+ 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);
+ }
+
+ static RailType.Configuration toConfiguration(RailType railType) {
+ return new RailType.Configuration()
+ .identifier(railType.getIdentifier())
+ .displayName(railType.getDisplayName())
+ .icon(railType.getIcon())
+ .railBlocks(railType.getRailBlocks())
+ .blocksBelow(railType.getBlocksBelow())
+ .sleeperBlocks(railType.getSleeperBlocks())
+ .sleeperSpacing(railType.getSleeperSpacing())
+ .trackCount(railType.getTrackCount())
+ .trackSpacing(railType.getTrackSpacing())
+ .trackSpacings(railType.getTrackSpacings())
+ .overheadPolesEnabled(railType.isOverheadPolesEnabled())
+ .overheadPoleBlocks(railType.getOverheadPoleBlocks())
+ .overheadSupportBlocks(railType.getOverheadSupportBlocks())
+ .overheadPoleSpacing(railType.getOverheadPoleSpacing())
+ .overheadPoleOffset(railType.getOverheadPoleOffset())
+ .overheadPoleHeight(railType.getOverheadPoleHeight())
+ .overheadWiresEnabled(railType.isOverheadWiresEnabled())
+ .overheadWireBlocks(railType.getOverheadWireBlocks())
+ .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..d71a54eb
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailBlockBuilder.java
@@ -0,0 +1,485 @@
+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 List railBlockTypes;
+ 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.railBlockTypes = railType.getRailBlocks().stream().map(this::getRailBlockType).toList();
+ 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) {
+ 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, centerPositions);
+ addSleeperBlock(railBlocks, center, perpendicularStep, -SLEEPER_SIDE_OFFSET, centerPositions);
+ }
+ }
+
+ private void addSleeperBlock(
+ Map railBlocks,
+ Vector center,
+ RailStep perpendicularStep,
+ int offset,
+ 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, GeneratorUtils.getBlockState(RailMaterialPalette.select(railType.getSleeperBlocks(), key)));
+ }
+
+ 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(RailMaterialPalette.select(railType.getSleeperBlocks(), PositionKey.from(position)));
+
+ 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
+ ) {
+ BlockType railBlockType = RailMaterialPalette.select(railBlockTypes, sideBlock.key());
+ Direction preferredDirection = sideBlock.getPreferredFacing();
+
+ if (railBlockType.getPropertyMap().containsKey(SHAPE_PROPERTY))
+ return createRailShapeBlockState(railBlockType,
+ resolveSideBlockFacing(sideBlock, sideBlocks),
+ getConnections(sideBlock.key(), sideBlocks)
+ );
+
+ if (!railBlockType.getPropertyMap().containsKey(FACING_PROPERTY))
+ return railBlockType.getDefaultState();
+
+ return GeneratorUtils.getBlockStateWithFacing(railBlockType, preferredDirection);
+ }
+
+ private BlockState createRailShapeBlockState(BlockType railBlockType, 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(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/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/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
new file mode 100644
index 00000000..f47c3609
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/generation/RailOverheadBuilder.java
@@ -0,0 +1,380 @@
+package net.buildtheearth.buildteamtools.modules.generator.components.rail.generation;
+
+import com.alpsbte.alpslib.utils.GeneratorUtils;
+import com.alpsbte.alpslib.utils.item.Item;
+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) {
+ List wireBlockTypes = railType.getOverheadWireBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList();
+
+ for (List path : railCenterPaths) {
+ List wirePath = createOverheadPath(path, overheadY);
+ addPathBlocks(blocks, wirePath, wireBlockTypes, false);
+ }
+ }
+
+ private void addPortals(
+ Map blocks,
+ List leftPath,
+ List rightPath,
+ int overheadY,
+ Set trackFootprint
+ ) {
+ List supportBlockTypes = railType.getOverheadSupportBlocks().stream().map(Item::convertXMaterialToWEBlockType).toList();
+
+ 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);
+ addPole(blocks, rightPole.x(), rightPole.z(), rightPole.surfaceY(), overheadY);
+ addPortalSupport(blocks, leftPole.x(), overheadY + 1, leftPole.z(), rightPole.x(), rightPole.z(), supportBlockTypes);
+ }
+ }
+
+ 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
+ ) {
+ 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(
+ Map blocks,
+ int startX,
+ int topY,
+ int startZ,
+ int endX,
+ int endZ,
+ List supportBlockTypes
+ ) {
+ Vector start = new Vector(startX, topY, startZ);
+ Vector end = new Vector(endX, topY, endZ);
+ addPathBlocks(blocks, createOrthogonalPath(start, end), supportBlockTypes, 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,
+ 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)
+ 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..fc19a4b4 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,18 @@
-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.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 +26,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 +48,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 +100,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);
@@ -104,7 +118,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);
@@ -121,6 +134,7 @@ private boolean prepareSession() {
selectionMinY,
selectionMaxY
);
+ generationRegion = GeneratorUtils.getWorldEditSelection(getPlayer());
blocks = GeneratorUtils.prepareScriptSession(
localSession,
@@ -132,6 +146,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);
@@ -141,7 +160,6 @@ private boolean prepareSession() {
snapMissingControlPointHeightsToTerrain(controlPoints);
centerPath = createCenterPath(controlPoints);
adjustCenterPathToTerrain();
- railType = getRailType();
preparationProgress.completeStage(TERRAIN_ADJUST_PROGRESS);
return true;
@@ -154,9 +172,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 +226,24 @@ 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());
+
for (Map.Entry entry : railBlocks.entrySet()) {
positions.add(entry.getKey().toVector());
blockStates.add(entry.getValue());
@@ -255,16 +312,6 @@ private boolean hasValidCenterPath() {
return true;
}
- private boolean hasSafeEstimatedBlockCount(List path) {
- long estimatedBlocks = (long) path.size() * getRailLaneCount() * 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.");
@@ -358,33 +405,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
+ || Permissions.checkPermission(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 +488,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 +522,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..3acc0033
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockPickerMenu.java
@@ -0,0 +1,167 @@
+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.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;
+
+/**
+ * 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) {
+ 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
+ protected void setPaginatedItemClickEventsAsync(List> source) {
+ List itemStacks = source.stream().map(l -> (ItemStack) l).toList();
+ int slot = 0;
+
+ 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.removeIf(type::equals);
+ } else {
+ if (role == RailBlockRole.ICON)
+ selectedMaterials.clear();
+ selectedMaterials.add(type);
+ }
+
+ reloadMenuAsync();
+ });
+ slot++;
+ }
+ }
+
+ @Override
+ protected void setItemClickEventsAsync() {
+ super.setItemClickEventsAsync();
+
+ 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();
+ });
+ });
+
+ getMenu().getSlot(NEXT_ITEM_SLOT).setClickHandler((clickPlayer, clickInformation) -> {
+ if (!canProceed())
+ return;
+ List materials = selectedMaterials.stream().map(Item::convertStringToXMaterial).toList();
+
+ if (materials.stream().anyMatch(Objects::isNull))
+ return;
+
+ role.apply(draft, materials);
+
+ 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..21bfef50
--- /dev/null
+++ b/src/main/java/net/buildtheearth/buildteamtools/modules/generator/components/rail/menu/RailBlockRole.java
@@ -0,0 +1,161 @@
+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.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.
+ * Each role provides its own predefined list of valid blocks to choose from.
+ */
+enum RailBlockRole {
+
+ RAIL_BLOCK("Choose a Rail Block") {
+ @Override
+ List createChoices() {
+ 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
+ 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.setBlocksBelow(List.of(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