diff --git a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java index e330fb5..045ae39 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java +++ b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java @@ -35,6 +35,7 @@ import net.tfminecraft.vehicleframework.tracks.TrackRegistry; import net.tfminecraft.vehicleframework.tracks.TrackToolListener; import net.tfminecraft.vehicleframework.vehicles.controller.GroundEngineLog; +import net.tfminecraft.vehicleframework.vehicles.handlers.TrainHandler; public class VehicleFramework extends JavaPlugin{ @@ -63,6 +64,7 @@ public void onEnable() { printBanner(); plugin = this; trackRegistry = new TrackRegistry(getDataFolder()); + trackRegistry.onRebuilt(TrainHandler::retrackTrains); log = new LogWriter(getDataFolder()); VFLogger.info("Running checks..."); createFolders(); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java index e9de002..718d1b0 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java @@ -13,6 +13,7 @@ import org.bukkit.entity.Player; import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.managers.VehicleManager; import net.tfminecraft.vehicleframework.permissions.Permissions; import net.tfminecraft.vehicleframework.VehicleFramework; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -343,8 +344,33 @@ public static void digAt(Player player, Location loc) { if (loc.getWorld() == null) { return; } - applyDig(player, registry().dig( - loc.getWorld().getName(), loc.getX(), loc.getY(), loc.getZ(), loc.getWorld()), loc); + Optional target = registry().digTarget( + loc.getWorld().getName(), loc.getX(), loc.getY(), loc.getZ()); + if (target.isEmpty()) { + applyDig(player, DigResult.none(), loc); + return; + } + if (trainOn(target.get())) { + lastToolMs.put(player.getUniqueId(), System.currentTimeMillis()); + player.sendMessage("§cA train is on this track. Move it before removing the rail."); + return; + } + applyDig(player, registry().digAt(target.get().spline(), target.get().index(), loc.getWorld()), loc); + } + + private static boolean trainOn(TrackRegistry.DigTarget target) { + VehicleManager vehicles = VehicleFramework.getVehicleManager(); + if (vehicles == null) { + return false; + } + UUID trackId = target.spline().getId(); + for (ActiveVehicle vehicle : vehicles.get().values()) { + if (vehicle.isTrain() && !vehicle.hasParent() + && vehicle.getTrainHandler().occupies(trackId, target.centreS(), target.halfSpan())) { + return true; + } + } + return false; } private static boolean delete(Player player, String[] args) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java index 2c81503..052f57c 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java @@ -11,6 +11,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; import org.bukkit.World; @@ -23,11 +24,23 @@ public final class TrackRegistry { private final TrackStore store; private final Map splines = new ConcurrentHashMap<>(); private final Map junctions = new ConcurrentHashMap<>(); + private BiConsumer> rebuilt = (old, next) -> { + }; public TrackRegistry(File dataFolder) { this.store = new TrackStore(dataFolder); } + /** + * Called when a spline's geometry is rebuilt, with the old spline and the + * splines that now carry its track. Arc lengths are not preserved, so + * anything bound to the old spline must re-find its position. + */ + public void onRebuilt(BiConsumer> listener) { + rebuilt = listener == null ? (old, next) -> { + } : listener; + } + public void loadFromDisk() { splines.clear(); junctions.clear(); @@ -147,11 +160,14 @@ public Optional findEnd(String world, double x, double y, double z) { return Optional.ofNullable(best); } - public DigResult dig(String world, double x, double y, double z) { - return dig(world, x, y, z, null); + /** + * The sample a dig at this point would remove, and the arc span of track + * it takes with it: {@code centreS} plus or minus {@code halfSpan}. + */ + public record DigTarget(TrackSpline spline, int index, double centreS, double halfSpan) { } - public DigResult dig(String world, double x, double y, double z, World bukkitWorld) { + public Optional digTarget(String world, double x, double y, double z) { TrackSpline spline = null; int index = -1; double best = Math.max(Cache.trackJoinDistance, 2.0); @@ -167,9 +183,25 @@ public DigResult dig(String world, double x, double y, double z, World bukkitWor } } if (spline == null) { - return DigResult.none(); + return Optional.empty(); } - return digAt(spline, index, bukkitWorld); + List samples = spline.getSamples(); + double digS = samples.get(index).s; + Optional turnout = turnoutDug(spline, digS); + if (turnout.isPresent()) { + // Digging a turnout removes the branch all the way back to the stem. + double turnoutEnd = turnout.get().turnoutEndS; + return Optional.of(new DigTarget(spline, index, turnoutEnd / 2, + turnoutEnd / 2 + TrackGenerate.STEP)); + } + double before = index > 0 ? digS - samples.get(index - 1).s : 0; + double after = index < samples.size() - 1 ? samples.get(index + 1).s - digS : 0; + if (spline.isLoop() && (index == 0 || index == samples.size() - 1)) { + double seam = spline.length() - samples.get(samples.size() - 1).s; + before = index == 0 ? seam : before; + after = index == 0 ? after : seam; + } + return Optional.of(new DigTarget(spline, index, digS, Math.max(before, after))); } public DigResult digAt(TrackSpline spline, int index) { @@ -182,14 +214,9 @@ public DigResult digAt(TrackSpline spline, int index, World bukkitWorld) { return DigResult.none(); } UUID id = spline.getId(); - Optional asBranch = junctionByBranch(id); - if (asBranch.isPresent()) { - TrackJunction junction = asBranch.get(); - double digS = spline.getSamples().get(index).s; - double turnoutEnd = junction.turnoutEndS; - if (turnoutEnd > 0 && digS <= turnoutEnd + 1e-9) { - return finishDig(removeJunctionTurnout(junction, bukkitWorld)); - } + Optional turnout = turnoutDug(spline, spline.getSamples().get(index).s); + if (turnout.isPresent()) { + return finishDig(removeJunctionTurnout(turnout.get(), bukkitWorld)); } List saved = List.copyOf(junctionsOn(id)); if (xyz.size() <= 2) { @@ -227,7 +254,15 @@ public DigResult digAt(TrackSpline spline, int index, World bukkitWorld) { dropJunctionsForSpline(id, spline.getWorld()); return finishDig(DigResult.deleted(id)); } + List pieces = new ArrayList<>(); + if (start != null) { + pieces.add(start); + } + if (rest != null) { + pieces.add(rest); + } rehomeJunctions(saved, spline, false, start, rest); + rebuilt.accept(spline, pieces); if (start != null && rest != null) { return finishDig(DigResult.split(start, rest)); } @@ -339,9 +374,12 @@ private StrokeLay connect(TrackEnd from, TrackEnd to, World bukkitWorld) throws from.spline.getWorld(), TrackSpline.shouldLoop(merged, Cache.trackJoinDistance), merged); - TrackSpline stored = replace(next); + TrackSpline stored = replaceQuietly(next); rehomeJunctions(keepSaved, oldKeep, from.prepend, stored); rehomeJunctions(dropSaved, oldDrop, !to.prepend, stored); + // Retrack only once junctions sit on the joined spline, so train routes survive. + rebuilt.accept(oldKeep, List.of(stored)); + rebuilt.accept(oldDrop, List.of(stored)); return new StrokeLay(stored, extra, 0); } @@ -376,6 +414,15 @@ public Optional get(UUID id) { } public TrackSpline replace(TrackSpline spline) { + TrackSpline previous = splines.get(spline.getId()); + TrackSpline next = replaceQuietly(spline); + if (previous != null && previous != next) { + rebuilt.accept(previous, List.of(next)); + } + return next; + } + + private TrackSpline replaceQuietly(TrackSpline spline) { TrackSpline next = spline.promotedLoop(Cache.trackJoinDistance); next.invalidateVisuals(); splines.put(next.getId(), next); @@ -925,6 +972,11 @@ private TrackLayResult finishLay(TrackLayResult result) { return result; } + private Optional turnoutDug(TrackSpline spline, double digS) { + return junctionByBranch(spline.getId()) + .filter(junction -> junction.turnoutEndS > 0 && digS <= junction.turnoutEndS + 1e-9); + } + private DigResult finishDig(DigResult result) { if (result.kind != DigResult.Kind.NONE) { pruneNestedShortTracks(); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java index 4a7bcf1..d54142d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -21,10 +21,12 @@ import net.tfminecraft.vehicleframework.database.ConsistData; import net.tfminecraft.vehicleframework.database.PersistenceLog; import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.managers.VehicleManager; import net.tfminecraft.vehicleframework.tracks.ThrottleTape; import net.tfminecraft.vehicleframework.tracks.ThrottleTapeItems; import net.tfminecraft.vehicleframework.tracks.TrackAdvance; import net.tfminecraft.vehicleframework.tracks.TrainBlockCollision; +import net.tfminecraft.vehicleframework.tracks.TrackClearance; import net.tfminecraft.vehicleframework.tracks.TrackFx; import net.tfminecraft.vehicleframework.tracks.TrackJunction; import net.tfminecraft.vehicleframework.tracks.TrackJunctionTravel; @@ -773,6 +775,95 @@ private boolean keepBound() { return true; } + /** Moves every train on {@code old} onto the track that replaced it. */ + public static void retrackTrains(TrackSpline old, List rebuilt) { + VehicleManager vehicles = VehicleFramework.getVehicleManager(); + if (vehicles == null) { + return; + } + for (ActiveVehicle vehicle : vehicles.get().values()) { + if (vehicle.isTrain()) { + vehicle.getTrainHandler().retrack(old, rebuilt); + } + } + } + + /** + * Keeps this car where it physically was after its track is rebuilt. + * Digging splits or trims a spline, which re-ids the far piece and shifts + * arc lengths; the stale {@code s} would otherwise teleport the train. + * If none of the rebuilt splines passes under the car it is left alone, + * and unbinds on its next tick if its spline is gone. + */ + public void retrack(TrackSpline old, List rebuilt) { + if (splineId == null || old == null || rebuilt == null || !splineId.equals(old.getId())) { + return; + } + TrackPose was = old.sampleAt(s); + TrackSpline best = null; + double bestS = 0; + double bestD = Double.POSITIVE_INFINITY; + for (TrackSpline candidate : rebuilt) { + double candidateS = candidate.nearestS(was.x, was.y, was.z); + TrackPose at = candidate.sampleAt(candidateS); + double horiz = Math.hypot(at.x - was.x, at.z - was.z); + double vert = Math.abs(at.y - was.y); + if (horiz > TrackClearance.OVERLAP_HORIZ || vert > TrackClearance.OVERLAP_VERT) { + continue; + } + double d = horiz * horiz + vert * vert; + if (d < bestD) { + best = candidate; + bestS = candidateS; + bestD = d; + } + } + if (best == null) { + return; + } + TrackRegistry registry = VehicleFramework.getTrackRegistry(); + if (!best.getId().equals(splineId) && registry != null && !routeTouches(registry, best.getId())) { + routeJunctionId = null; + takeBranch = false; + } + PersistenceLog.append("RETRACK " + PersistenceLog.vehicle(v) + + " from=" + splineId + "@" + s + " to=" + best.getId() + "@" + bestS); + splineId = best.getId(); + s = bestS; + } + + private boolean routeTouches(TrackRegistry registry, UUID trackId) { + if (routeJunctionId == null) { + return false; + } + TrackJunction route = registry.getJunction(routeJunctionId).orElse(null); + return route != null + && (trackId.equals(route.stemSplineId) || trackId.equals(route.branchSplineId)); + } + + /** + * Whether any car of this consist sits within {@code halfSpan} of arc + * length {@code at} on the given track, counting each car out to its couplers. + */ + public boolean occupies(UUID trackId, double at, double halfSpan) { + if (trackId == null || v == null || v.hasParent() || boundSpline() == null) { + return false; + } + for (CarPlacement car : planCars()) { + if (!car.spline.getId().equals(trackId)) { + continue; + } + double d = Math.abs(car.s - at); + if (car.spline.isLoop()) { + d = Math.min(d, car.spline.length() - d); + } + if (d <= reach(car.vehicle.getTrainHandler()) + halfSpan) { + return true; + } + } + return false; + } + private boolean tryBindOrKeep() { if (keepBound()) { return true; @@ -1116,18 +1207,24 @@ private static ActiveVehicle locoOf(ActiveVehicle car) { } private static double spacing(TrainHandler parent, TrainHandler child) { - double back = 0; - double front = 0; + double back = parent != null && parent.canHaveAttached() ? offsetLength(parent.getBack()) : 0; + double front = child != null && child.isAttachable() ? offsetLength(child.getFront()) : 0; + return TrackConsistMath.connectorSpacing(back, front); + } + + // How far along the track a car extends from its centre to its couplers. + private static double reach(TrainHandler car) { + double front = car.isAttachable() ? offsetLength(car.getFront()) : 0; + double back = car.canHaveAttached() ? offsetLength(car.getBack()) : 0; + return Math.max(1.0, Math.max(front, back)); + } + + private static double offsetLength(Connector connector) { try { - if (parent != null && parent.canHaveAttached()) { - back = parent.getBack().getOffset().length(); - } - if (child != null && child.isAttachable()) { - front = child.getFront().getOffset().length(); - } + return connector.getOffset().length(); } catch (Exception ignored) { - return TrackConsistMath.connectorSpacing(back, front); + // Offsets come from the live model and are unavailable until it loads. + return 0; } - return TrackConsistMath.connectorSpacing(back, front); } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java index 3e75d65..413f0cd 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -11,6 +12,7 @@ import java.lang.reflect.Field; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -390,6 +392,138 @@ void reversalWhileCarsSpanJunctionKeepsRouteAndSpacing(int facing) { } } + @Test + void splittingTrackBehindTrainKeepsItWhereItWas() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.digAt(track, 20); + loco.splineTick(); + assertNotEquals(track.getId(), loco.getSplineId(), "Train must move onto the far piece"); + assertPositions(loco, registry.get(loco.getSplineId()).orElseThrow(), 39); + assertEquals(60, loco.v.getEntity().getLocation().getZ(), 1e-8); + assertEquals(40, loco.getChild().getTrainHandler().getChild().getTrainHandler() + .v.getEntity().getLocation().getZ(), 1e-8); + } + + @Test + void trainOnSplitTrackCanDriveOn() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.digAt(track, 20); + loco.v.getAccessPanel().setSpeed(0.2); + loco.splineTick(); + assertEquals(60.2, loco.v.getEntity().getLocation().getZ(), 1e-8); + assertEquals(40.2, loco.getChild().getTrainHandler().getChild().getTrainHandler() + .v.getEntity().getLocation().getZ(), 1e-8); + } + + @Test + void trimmingTrackStartDoesNotShiftTrain() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.digAt(track, 0); + loco.splineTick(); + assertEquals(track.getId(), loco.getSplineId()); + assertPositions(loco, registry.get(track.getId()).orElseThrow(), 59); + assertEquals(60, loco.v.getEntity().getLocation().getZ(), 1e-8); + } + + @Test + void savingParkedTrainAfterSplitStoresItsRealPosition() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.digAt(track, 20); + ConsistData saved = loco.toConsistData(); + assertNotEquals(track.getId().toString(), saved.getSplineId()); + assertEquals(39, saved.getS(), 1e-8); + } + + @Test + void removingTrackUnderParkedTrainUnbindsIt() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.delete(track.getId()); + loco.splineTick(); + assertFalse(loco.isBound()); + } + + @Test + void splittingTrackAtCrossingKeepsTrainOnItsOwnLine() { + TrackSpline track = denseTrack(); + TrackSpline crossing = crossingAt(60); + TrainHandler loco = consist(registry.get(track.getId()).orElseThrow(), 60); + registry.digAt(registry.get(track.getId()).orElseThrow(), 20); + assertNotEquals(crossing.getId(), loco.getSplineId()); + assertNotEquals(track.getId(), loco.getSplineId()); + assertEquals(39, loco.getS(), 1e-8); + } + + @Test + void splittingStemKeepsRouteOfTrainLeavingBranch() { + TrackSpline stem = denseTrack(); + TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0, 64, 50}, new double[]{100, 64, 50})); + store.save(branch); + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 50, + -1, TrackJunction.Side.LEFT, branch.getId(), true); + store.saveJunction("world", junction); + registry.loadFromDisk(); + TrainHandler loco = consist(registry.get(stem.getId()).orElseThrow(), 55); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), 55d, + 1, junction.id.toString(), true)); + loco.placeLoadedCars(); + TrainHandler first = loco.getChild().getTrainHandler(); + assertEquals(branch.getId(), first.getSplineId(), "Setup: first car trails onto the branch"); + Location before = first.v.getEntity().getLocation(); + + registry.digAt(registry.get(stem.getId()).orElseThrow(), 10); + loco.splineTick(); + + assertNotEquals(stem.getId(), loco.getSplineId()); + assertEquals(junction.id.toString(), loco.toConsistData().getJunctionId()); + assertEquals(branch.getId(), first.getSplineId()); + assertEquals(before.getX(), first.v.getEntity().getLocation().getX(), 1e-8); + assertEquals(before.getZ(), first.v.getEntity().getLocation().getZ(), 1e-8); + } + + @Test + void deletingTrackDoesNotMoveTrainOntoCrossingTrack() { + TrackSpline track = denseTrack(); + crossingAt(60); + TrainHandler loco = consist(registry.get(track.getId()).orElseThrow(), 60); + registry.delete(track.getId()); + loco.splineTick(); + assertFalse(loco.isBound()); + } + + @Test + void breakingTrackPieceElsewhereKeepsTrainPosition() { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + registry.replace(track.withSegment(80, track.segment(80).withBroken(true))); + loco.splineTick(); + assertPositions(loco, track, 60); + } + + @ParameterizedTest + @CsvSource({"45, 1, true", "66, 1, true", "34, 1, true", "33.5, 1, false", + "67.5, 1, false", "20, 1, false"}) + void consistOccupiesTrackOutToItsCouplers(double at, double halfSpan, boolean occupied) { + TrackSpline track = denseTrack(); + TrainHandler loco = consist(track, 60); + assertEquals(occupied, loco.occupies(track.getId(), at, halfSpan)); + } + + @Test + void digTargetCoversTheEdgesItRemoves() { + TrackSpline track = denseTrack(); + TrackRegistry.DigTarget target = registry.digTarget("world", 0, 64, 20.2).orElseThrow(); + assertEquals(20, target.index()); + assertEquals(20, target.centreS(), 1e-8); + assertEquals(1, target.halfSpan(), 1e-8); + assertEquals(track.getId(), target.spline().getId()); + } + private void assertSequence(TrainHandler loco, TrackSpline track, double[] expected) { double[] speeds = {0.2, 0, -0.01, 0, 0.2}; for (int i = 0; i < speeds.length; i++) { @@ -413,6 +547,25 @@ private void assertPositions(TrainHandler loco, TrackSpline track, double s) { } } + private TrackSpline denseTrack() { + List points = new ArrayList<>(); + for (int z = 0; z <= 100; z++) { + points.add(new double[]{0, 64, z}); + } + TrackSpline track = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, points); + store.save(track); + registry.loadFromDisk(); + return registry.get(track.getId()).orElseThrow(); + } + + private TrackSpline crossingAt(double z) { + TrackSpline crossing = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{-20, 64, z}, new double[]{20, 64, z})); + store.save(crossing); + registry.loadFromDisk(); + return crossing; + } + private TrackSpline straightTrack(boolean loop) { TrackSpline track = TrackSpline.fromPoints(UUID.randomUUID(), "world", loop, List.of(new double[]{0, 64, 0}, new double[]{0, 64, 100})); @@ -427,6 +580,8 @@ private TrainHandler consist(TrackSpline track, double s) { TrainHandler second = car(); loco.setChild(first.v); first.setChild(second.v); + registry.onRebuilt((old, rebuilt) -> List.of(loco, first, second) + .forEach(car -> car.retrack(old, rebuilt))); loco.setSplineId(track.getId()); loco.setS(s); loco.placeLoadedCars();