diff --git a/README.md b/README.md index 8175387..05146aa 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,24 @@ short for that radius curves tighter, down to the sharpest turn that Other clicks lay a single arc, as before. Existing track is unchanged. +## Junctions in reverse + +Hold A or D to choose a turnout within `junction-arm-distance` of the leading +wheels. Left and right are viewed in the direction the train is travelling. +While stopped, the throttle selects the approach direction; at zero throttle, +the train remembers its last direction. + +Choose before the first wheels enter. The points stay locked until the whole +train clears, including when the last carriage leads while reversing. Stopping, +reversing midway, or saving and loading keeps every coupled car on the same +route. Closely spaced junctions retain their own choices while the train spans +them. Coming out of a branch follows its connection back onto the main track. + +Train facing and occupied junction choices are saved with the consist. Existing +saves and throttle tapes remain readable. Before downgrading this version, +restore the matching vehicle-data backup: older versions cannot represent a +train facing the opposite way along a track. + ## Track ends Set `behaviour.train.wheel-bones` to the model bones at the frontmost and rearmost diff --git a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java index 45b1c03..84ff32e 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java +++ b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java @@ -68,6 +68,7 @@ public void onEnable() { trackRegistry = new TrackRegistry(getDataFolder()); trackRegistry.writeInBackground(); trackRegistry.onRebuilt(TrainHandler::retrackTrains); + trackRegistry.onJunctionOccupied(TrainHandler::junctionOccupied); trackRegistry.occupiedBy(TrainHandler::anyTrainOn); log = new LogWriter(getDataFolder()); VFLogger.info("Running checks..."); diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/ConsistData.java b/src/main/java/net/tfminecraft/vehicleframework/database/ConsistData.java index 9d3697e..e765ff6 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/ConsistData.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/ConsistData.java @@ -1,5 +1,8 @@ package net.tfminecraft.vehicleframework.database; +import java.util.LinkedHashMap; +import java.util.Map; + import org.json.simple.JSONObject; public final class ConsistData { @@ -10,6 +13,8 @@ public final class ConsistData { private final Integer travelSign; private final String junctionId; private final Boolean diverge; + private final int orientation; + private final Map junctions; public ConsistData(String parent, String child, String splineId, Double s) { this(parent, child, splineId, s, null, null, null); @@ -27,6 +32,11 @@ public ConsistData( Integer travelSign, String junctionId, Boolean diverge) { + this(parent, child, splineId, s, travelSign, junctionId, diverge, 1, Map.of()); + } + + public ConsistData(String parent, String child, String splineId, Double s, Integer travelSign, + String junctionId, Boolean diverge, int orientation, Map junctions) { this.parent = blankToNull(parent); this.child = blankToNull(child); this.splineId = blankToNull(splineId); @@ -34,6 +44,15 @@ public ConsistData( this.travelSign = this.splineId == null ? null : normalizeSign(travelSign); this.junctionId = this.splineId == null ? null : blankToNull(junctionId); this.diverge = this.junctionId == null ? null : diverge; + this.orientation = normalizeSign(orientation); + Map routes = new LinkedHashMap<>(); + if (this.junctionId != null) { + routes.put(this.junctionId, Boolean.TRUE.equals(diverge)); + } + if (this.splineId != null && junctions != null) { + routes.putAll(junctions); + } + this.junctions = Map.copyOf(routes); } public static ConsistData unbound() { @@ -51,7 +70,9 @@ public static ConsistData fromJson(JSONObject json) { numberOrNull(json, "s"), intOrNull(json, "travelSign"), stringOrNull(json, "junction"), - boolOrNull(json, "diverge")); + boolOrNull(json, "diverge"), + normalizeSign(intOrNull(json, "orientation")), + readJunctions(json)); } @SuppressWarnings("unchecked") @@ -71,6 +92,14 @@ public void put(JSONObject json) { json.put("s", s); } json.put("travelSign", (long) (travelSign == null ? 1 : travelSign)); + if (orientation < 0) { + json.put("orientation", -1L); + } + if (!junctions.isEmpty()) { + JSONObject routes = new JSONObject(); + routes.putAll(junctions); + json.put("junctions", routes); + } if (junctionId != null) { json.put("junction", junctionId); if (Boolean.TRUE.equals(diverge)) { @@ -112,6 +141,26 @@ public boolean isDiverge() { return Boolean.TRUE.equals(diverge); } + public int getOrientation() { + return orientation; + } + + public Map getJunctions() { + return junctions; + } + + private static Map readJunctions(JSONObject json) { + Map routes = new LinkedHashMap<>(); + if (json.get("junctions") instanceof JSONObject raw) { + for (Object key : raw.keySet()) { + if (key instanceof String id && raw.get(key) instanceof Boolean choice) { + routes.put(id, choice); + } + } + } + return routes; + } + private static Integer intOrNull(JSONObject json, String key) { Double n = numberOrNull(json, key); if (n == null) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java index b5aade8..05b54c0 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java @@ -16,6 +16,7 @@ public static final class Sample { public final double s; public final int sign; public final int throttle; + public final int orientation; public int holdTicks; public final String splineId; public final String junctionId; @@ -29,6 +30,11 @@ public Sample(double s, int sign, int throttle, int holdTicks) { } public Sample(double s, int sign, int throttle, int holdTicks, String splineId, String junctionId) { + this(s, sign, throttle, holdTicks, splineId, junctionId, 1); + } + + public Sample(double s, int sign, int throttle, int holdTicks, String splineId, String junctionId, int orientation) { + this.orientation = orientation < 0 ? -1 : 1; this.s = s; this.sign = sign < 0 ? -1 : 1; this.throttle = throttle; @@ -120,21 +126,21 @@ static int lookup(List samples, double s, int sign) { } matched.sort(Comparator.comparingDouble(a -> a.s)); if (s <= matched.get(0).s) { - return matched.get(0).throttle; + return matched.get(0).throttle * matched.get(0).orientation; } Sample last = matched.get(matched.size() - 1); if (s >= last.s) { - return last.throttle; + return last.throttle * last.orientation; } for (int i = 0; i < matched.size() - 1; i++) { Sample a = matched.get(i); Sample b = matched.get(i + 1); if (s >= a.s && s <= b.s) { if (b.s == a.s) { - return a.throttle; + return a.throttle * a.orientation; } double t = (s - a.s) / (b.s - a.s); - return (int) Math.round(a.throttle + t * (b.throttle - a.throttle)); + return (int) Math.round(a.throttle * a.orientation + t * (b.throttle * b.orientation - a.throttle * a.orientation)); } } return 0; @@ -173,6 +179,11 @@ public int targetWithDwell(double s, int sign, DwellState dwell, UUID currentSpl return targetWithDwell(s, sign, dwell, currentSpline == null ? splineId : currentSpline.toString()); } + /** Converts each recorded sample from its body frame, then into the current body's frame. */ + public int targetWithDwell(double s, int sign, DwellState dwell, UUID currentSpline, int orientation) { + return targetWithDwell(s, sign, dwell, currentSpline) * (orientation < 0 ? -1 : 1); + } + public int targetWithDwell(double s, int sign, DwellState dwell, String currentSpline) { int target = lookup(s, sign, currentSpline); int hold = holdAt(s, sign, currentSpline); @@ -200,6 +211,10 @@ public AppendResult tryAppend(double s, int sign, int throttle) { } public AppendResult tryAppend(double s, int sign, int throttle, String sampleSpline, String junctionId) { + return tryAppend(s, sign, throttle, sampleSpline, junctionId, 1); + } + + public AppendResult tryAppend(double s, int sign, int throttle, String sampleSpline, String junctionId, int orientation) { int nsign = sign < 0 ? -1 : 1; String spline = blankToNull(sampleSpline); if (spline == null) { @@ -211,7 +226,7 @@ public AppendResult tryAppend(double s, int sign, int throttle, String sampleSpl String lastSpline = last.resolvedSpline(this.splineId); boolean splineChanged = !sameId(lastSpline, spline); boolean junctionChanged = !sameId(last.junctionId, junction); - boolean throttleChanged = last.throttle != throttle || last.sign != nsign; + boolean throttleChanged = last.throttle != throttle || last.sign != nsign || last.orientation != orientation; boolean far = Math.abs(s - last.s) >= HOLD_S; if (!splineChanged && !junctionChanged && !throttleChanged && !far) { last.holdTicks++; @@ -221,7 +236,7 @@ public AppendResult tryAppend(double s, int sign, int throttle, String sampleSpl if (samples.size() >= MAX_SAMPLES) { return AppendResult.CAPPED; } - samples.add(new Sample(s, nsign, throttle, 1, spline, junction)); + samples.add(new Sample(s, nsign, throttle, 1, spline, junction, orientation)); if (samples.size() >= MAX_SAMPLES) { return AppendResult.CAPPED; } @@ -238,6 +253,7 @@ public JSONObject toJson() { o.put("s", sample.s); o.put("sign", (long) sample.sign); o.put("throttle", (long) sample.throttle); + if (sample.orientation < 0) { o.put("orientation", -1L); } o.put("hold", (long) sample.holdTicks); if (sample.splineId != null && !sample.splineId.equalsIgnoreCase(splineId)) { o.put("splineId", sample.splineId); @@ -277,7 +293,8 @@ public static ThrottleTape fromJson(JSONObject root) { (int) asDouble(o.get("throttle")), hold, stringOrNull(o.get("splineId")), - stringOrNull(o.get("junction")))); + stringOrNull(o.get("junction")), + o.get("orientation") == null ? 1 : (int) asDouble(o.get("orientation")))); } } return new ThrottleTape(splineId, loaded); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java index 742d156..50d895b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java @@ -31,11 +31,20 @@ public final class TrackRegistry { private BiConsumer> rebuilt = (old, next) -> { }; private Predicate occupied = id -> false; + private Predicate junctionOccupied = id -> false; public TrackRegistry(File dataFolder) { this.store = new TrackStore(dataFolder); } + public void onJunctionOccupied(Predicate occupied) { + junctionOccupied = occupied == null ? id -> false : occupied; + } + + public boolean junctionOccupied(UUID id) { + return junctionOccupied.test(id); + } + /** * 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 @@ -836,7 +845,7 @@ public boolean setThrown(UUID id, boolean thrown) { if (junction == null) { return false; } - if (junction.thrown == thrown) { + if (junction.thrown == thrown || junctionOccupied(id)) { return false; } TrackJunction next = junction.withThrown(thrown); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java new file mode 100644 index 0000000..f25d356 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java @@ -0,0 +1,135 @@ +package net.tfminecraft.vehicleframework.tracks; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** Walks the rails in a train's body frame, preserving orientation at each turnout. */ +public final class TrainRoute { + + public record Position(UUID splineId, double s, int orientation) { + public Position { + orientation = orientation < 0 ? -1 : 1; + } + } + + public record Walk(Position position, double missing, boolean broken, Map junctions) { + } + + private final TrackRegistry registry; + private final Map choices; + + public TrainRoute(TrackRegistry registry, Map choices) { + this.registry = registry; + this.choices = choices; + } + + /** + * Positive distance is towards the front coupler, negative towards the back. + * Only the leading wheels select new switches; all other walks use frozen choices. + */ + public Walk walk(Position start, double distance, boolean select, boolean stopAtBreak) { + Position at = start; + double left = Math.abs(distance); + int bodyDirection = distance < 0 ? -1 : 1; + Map crossed = new LinkedHashMap<>(); + Set atPoint = new HashSet<>(); + // Bounds malformed/zero-length connections, while allowing loops and long consists. + for (int events = 0; left > 1e-9 && events < 1024; events++) { + TrackSpline spline = registry.get(at.splineId()).orElse(null); + if (spline == null || spline.length() < 1e-9) { + return new Walk(at, left, false, crossed); + } + int direction = bodyDirection * at.orientation(); + double end = spline.isLoop() ? Double.POSITIVE_INFINITY + : direction > 0 ? spline.length() - at.s() : at.s(); + TrackJunction next = null; + boolean fromBranch = false; + double ahead = Math.max(0, end); + if (!spline.isLoop() && direction < 0) { + next = registry.junctionByBranch(at.splineId()).orElse(null); + fromBranch = next != null; + } + for (TrackJunction junction : registry.junctionsOn(at.splineId())) { + if (junction.branchSplineId == null || registry.get(junction.branchSplineId).isEmpty()) { + continue; + } + double d = TrackJunctionTravel.ahead(at.s(), junction.s, direction, + spline.isLoop(), spline.length()); + if (d < -1e-9 || (d <= 1e-9 && atPoint.contains(junction.id))) { + continue; + } + if (d <= ahead + 1e-9) { + next = junction; + fromBranch = false; + ahead = Math.max(0, d); + } + } + double step = Math.min(left, ahead); + double nextS = TrackJunction.wrapS(at.s() + direction * step, spline.length(), spline.isLoop()); + if (stopAtBreak) { + TrackAdvance advanced = spline.advance(at.s(), direction * step); + if (advanced.stoppedAtBreak) { + double moved = TrackJunctionTravel.ahead(at.s(), advanced.s, direction, + spline.isLoop(), spline.length()); + return new Walk(new Position(at.splineId(), advanced.s, at.orientation()), + Math.max(0, left - moved), true, crossed); + } + nextS = advanced.s; + } + at = new Position(at.splineId(), nextS, at.orientation()); + left = Math.max(0, left - step); + if (step > 1e-9) { + atPoint.clear(); + } + if (ahead > step + 1e-9) { + break; + } + if (next == null) { + return new Walk(at, left, false, crossed); + } + if (fromBranch) { + TrackSpline stem = registry.get(next.stemSplineId).orElse(null); + if (stem == null) { + return new Walk(at, left, false, crossed); + } + crossed.put(next.id, true); + at = new Position(next.stemSplineId, next.s, at.orientation() * next.facingSign); + atPoint.add(next.id); + } else { + boolean diverge = direction == next.facingSign + && choices.getOrDefault(next.id, select && next.thrown); + crossed.put(next.id, diverge); + atPoint.add(next.id); + if (diverge) { + at = new Position(next.branchSplineId, 0, at.orientation() * next.facingSign); + } + } + } + return new Walk(at, left, false, crossed); + } + + public Walk walk(Position start, double distance) { + return walk(start, distance, false, false); + } + + /** Sample a body-facing rail pose, extrapolating only the unsupported part at a real end. */ + public TrackPose rail(Position start, double offset) { + Walk walk = walk(start, offset); + Position at = walk.position(); + TrackSpline spline = registry.get(at.splineId()).orElse(null); + if (spline == null) { + return null; + } + double beyond = (offset < 0 ? -1 : 1) * at.orientation() * walk.missing(); + return facing(net.tfminecraft.vehicleframework.vehicles.handlers.train.Bogies.rail(spline, at.s() + beyond), + at.orientation()); + } + + public static TrackPose facing(TrackPose pose, int orientation) { + return orientation < 0 + ? new TrackPose(pose.x, pose.y, pose.z, pose.yaw + 180, -pose.pitch) : pose; + } +} 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 8872af9..7960ce4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -33,7 +34,8 @@ 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.TrainRoute; +import net.tfminecraft.vehicleframework.tracks.TrainRoute.Position; import net.tfminecraft.vehicleframework.tracks.TrainBlockCollision; import net.tfminecraft.vehicleframework.tracks.TrainSpaceHighlight; import net.kyori.adventure.text.Component; @@ -76,6 +78,9 @@ public class TrainHandler { // Track may have been edited while this train was unloaded. private boolean checkLoadedPosition; private int travelSign = 1; + // The nose direction on this spline, independent of forward/reverse movement. + private int orientation = 1; + private final Map junctionRoutes = new LinkedHashMap<>(); private UUID armedJunctionId; private TrackJunction.Side armedSide; private long lastNoFrogChatMs; @@ -149,6 +154,8 @@ public TrainHandler(ActiveVehicle v, TrainHandler another) { back = new Connector(v, another.getBack()); } travelSign = another.travelSign; + orientation = another.orientation; + junctionRoutes.putAll(another.junctionRoutes); armedJunctionId = another.armedJunctionId; armedSide = another.armedSide; routeJunctionId = another.routeJunctionId; @@ -239,6 +246,16 @@ public void setChild(ActiveVehicle v) { child = v; if (v != null) { pendingChild = null; + // A carriage can load before the locomotive. Recover its saved choices + // before the first placement crosses a turnout to reconstruct the chain. + ActiveVehicle loco = locoOf(this.v); + if (loco != null) { + TrainHandler root = loco.getTrainHandler(); + for (ActiveVehicle car = v; car != null; car = car.getTrainHandler().child) { + car.getTrainHandler().junctionRoutes.forEach(root.junctionRoutes::putIfAbsent); + } + root.syncRoute(); + } } } @@ -292,6 +309,14 @@ public void applyConsist(ConsistData consist) { s = consist.getS() == null ? 0 : consist.getS(); checkLoadedPosition = splineId != null; travelSign = consist.getTravelSign(); + orientation = consist.getOrientation(); + junctionRoutes.clear(); + consist.getJunctions().forEach((id, choice) -> { + try { junctionRoutes.put(UUID.fromString(id), choice); } + catch (IllegalArgumentException ignored) { + // A malformed route id must not prevent loading the remaining consist. + } + }); routeJunctionId = null; takeBranch = consist.isDiverge(); if (consist.getJunctionId() != null) { @@ -302,6 +327,7 @@ public void applyConsist(ConsistData consist) { takeBranch = false; } } + syncRoute(); PersistenceLog.append("APPLY_CONSIST " + PersistenceLog.vehicle(v)); } @@ -313,86 +339,77 @@ public ConsistData toConsistData() { boolean loco = pendingParent == null && (v == null || !v.hasParent()); String junction = loco && routeJunctionId != null ? routeJunctionId.toString() : null; Boolean diverge = junction == null ? null : takeBranch; - return new ConsistData(parentId, childId, spline, arc, splineId == null ? null : travelSign, junction, diverge); + Map routes = new LinkedHashMap<>(); + junctionRoutes.forEach((id, choice) -> routes.put(id.toString(), choice)); + return new ConsistData(parentId, childId, spline, arc, splineId == null ? null : travelSign, + junction, diverge, orientation, routes); } public void holdJunction(TrackJunction.Side side) { - if (side == null || v == null || v.hasParent()) { - return; - } - if (v.getAccessPanel() != null && v.getAccessPanel().getSpeed() < 0) { - clearArm(); - tellCaptain("Junction arm cleared: reverse"); - RecorderLog.arm(v, "clear-reverse", side, null, 0); - return; - } - TrackSpline spline = boundSpline(); - if (spline == null) { - tellCaptain("Junction: not on a track"); + if (side == null || v == null || v.hasParent() || boundSpline() == null) { return; } TrackRegistry registry = VehicleFramework.getTrackRegistry(); if (registry == null) { return; } - if (armedJunctionId != null) { - TrackJunction armed = registry.getJunction(armedJunctionId).orElse(null); - if (armed != null && armed.stemSplineId.equals(splineId) - && TrackJunctionTravel.facing(travelSign, armed.facingSign)) { - double ahead = TrackJunctionTravel.ahead( - s, armed.s, travelSign, spline.isLoop(), spline.length()); - boolean stillBefore = ahead >= -1e-9 - && ahead <= Cache.trackJunctionArmDistance + 1e-9 - && (!spline.isLoop() || ahead <= spline.length() * 0.5 + 1e-9); - if (stillBefore) { - throwSwitch(registry, armed, side, ahead); - return; - } + Map used = new LinkedHashMap<>(); + List cars = planCars(used); + retainRoutes(cars, used); + int direction = controlDirection(); + Position lead = leadingPosition(cars, direction); + if (lead == null) { + return; + } + TrackSpline spline = registry.get(lead.splineId()).orElse(null); + TrackJunction next = null; + double nearest = Double.POSITIVE_INFINITY; + for (TrackJunction junction : registry.junctionsOn(lead.splineId())) { + if (junction.branchSplineId == null || direction * lead.orientation() != junction.facingSign) { + continue; + } + double ahead = TrackJunctionTravel.ahead(lead.s(), junction.s, direction * lead.orientation(), + spline.isLoop(), spline.length()); + if (ahead >= -1e-9 && ahead < nearest) { + next = junction; + nearest = ahead; } - clearArm(); } - TrackJunction next = nextFrogInWindow(registry, spline); - if (next == null) { - TrackJunction far = nextFrogAhead(registry, spline, Double.POSITIVE_INFINITY); + if (next == null || nearest > Cache.trackJunctionArmDistance) { long now = System.currentTimeMillis(); if (now - lastNoFrogChatMs >= 1500) { lastNoFrogChatMs = now; - if (far == null) { - tellCaptain("Junction: no turnout ahead on this track"); - RecorderLog.arm(v, "no-frog", side, null, 0); - } else { - double ahead = TrackJunctionTravel.ahead( - s, far.s, travelSign, spline.isLoop(), spline.length()); - tellCaptain("Junction: press A/D within " - + (int) Cache.trackJunctionArmDistance - + " of the frog. Next is " - + far.side.name().toLowerCase() - + " at s=" - + String.format(java.util.Locale.US, "%.0f", far.s) - + " (" - + String.format(java.util.Locale.US, "%.0f", ahead) - + " ahead)"); - RecorderLog.arm(v, "too-far", side, far, ahead); - } + tellCaptain(next == null ? "Junction: no facing turnout ahead of the leading car" + : "Junction: press A/D within " + (int) Cache.trackJunctionArmDistance + + " of the leading wheels reaching the turnout"); + } + return; + } + boolean diverge = next.side == side; + if ((junctionRoutes.containsKey(next.id) || registry.junctionOccupied(next.id)) && next.thrown != diverge) { + if (System.currentTimeMillis() - lastNoFrogChatMs >= 1500) { + lastNoFrogChatMs = System.currentTimeMillis(); + tellCaptain("Junction: points locked until the whole train clears"); } return; } - double ahead = TrackJunctionTravel.ahead( - s, next.s, travelSign, spline.isLoop(), spline.length()); - throwSwitch(registry, next, side, ahead); + boolean first = !next.id.equals(armedJunctionId); + boolean changed = registry.setThrown(next.id, diverge); + armedJunctionId = next.id; + armedSide = side; + if (first || changed) { + tellArm(registry.getJunction(next.id).orElse(next), nearest, diverge ? "diverge" : "through"); + } } - private void throwSwitch(TrackRegistry registry, TrackJunction frog, TrackJunction.Side side, double ahead) { - boolean first = armedJunctionId == null || !armedJunctionId.equals(frog.id); - boolean diverge = frog.side == side; - boolean changed = registry.setThrown(frog.id, diverge); - armedJunctionId = frog.id; - armedSide = side; - if (!first && !changed) { - return; + // Actual velocity wins while braking/coasting; throttle only chooses direction at rest. + private int controlDirection() { + double speed = v.getAccessPanel() == null ? 0 : v.getAccessPanel().getSpeed(); + if (!TrackSplineMotion.stopped(speed)) { + return speed < 0 ? -1 : 1; } - TrackJunction live = registry.getJunction(frog.id).orElse(frog); - tellArm(live, ahead, diverge ? "diverge" : "through"); + int throttle = v.getThrottle() == null ? 0 : v.getThrottle().getCurrent(); + return throttle == 0 ? travelSign * orientation : throttle < 0 ? -1 : 1; } private void tellArm(TrackJunction frog, double ahead, String status) { @@ -416,33 +433,6 @@ private void tellCaptain(String message) { } } - private TrackJunction nextFrogInWindow(TrackRegistry registry, TrackSpline spline) { - return nextFrogAhead(registry, spline, Cache.trackJunctionArmDistance); - } - - private TrackJunction nextFrogAhead(TrackRegistry registry, TrackSpline spline, double maxAhead) { - TrackJunction best = null; - double bestAhead = Double.POSITIVE_INFINITY; - for (TrackJunction junction : registry.junctionsOn(splineId)) { - if (junction.branchSplineId == null) { - continue; - } - if (!TrackJunctionTravel.facing(travelSign, junction.facingSign)) { - continue; - } - double ahead = TrackJunctionTravel.ahead( - s, junction.s, travelSign, spline.isLoop(), spline.length()); - if (ahead < -1e-9 || ahead > maxAhead) { - continue; - } - if (ahead < bestAhead) { - bestAhead = ahead; - best = junction; - } - } - return best; - } - private void clearArm() { armedJunctionId = null; armedSide = null; @@ -459,6 +449,10 @@ public int getTravelSign() { return travelSign; } + public int getOrientation() { + return orientation; + } + public boolean hasInstalledTape() { return installedTape != null && !installedTape.isEmpty(); } @@ -503,7 +497,7 @@ public void startRecording(Player player) { recordTraveled = 0; recordLength = spline.length(); int throttle = v != null && v.getThrottle() != null ? v.getThrottle().getCurrent() : 0; - recordingTape.tryAppend(s, travelSign, throttle, splineId.toString(), null); + recordingTape.tryAppend(s, travelSign, throttle, splineId.toString(), null, orientation); RecorderLog.append("RECORD_START player=" + (player == null ? "none" : player.getName()) + " length=" + recordLength + " " + RecorderLog.train(v)); } @@ -533,12 +527,10 @@ public void maybeRecordSample(int throttle) { } recordPrevS = s; recordPrevSpline = spline.getId(); - String junction = null; - if (!onOrigin && routeJunctionId != null) { - junction = routeJunctionId.toString(); - } + String junction = onOrigin ? null : registry.junctionByBranch(splineId) + .map(branch -> branch.id.toString()).orElse(null); ThrottleTape.AppendResult result = recordingTape.tryAppend( - s, travelSign, throttle, spline.getId().toString(), junction); + s, travelSign, throttle, spline.getId().toString(), junction, orientation); int hold = recordingTape.getSamples().isEmpty() ? 0 : recordingTape.getSamples().get(recordingTape.getSamples().size() - 1).holdTicks; @@ -606,7 +598,7 @@ public Integer playbackThrottle(FuelTank tank) { RecorderLog.playback(v, "captain", null, tapeDwell); return null; } - int target = installedTape.targetWithDwell(s, travelSign, tapeDwell, splineId); + int target = installedTape.targetWithDwell(s, travelSign, tapeDwell, splineId, orientation); RecorderLog.playback(v, "ok", target, tapeDwell); return target; } @@ -621,6 +613,8 @@ public void unbind() { splineId = null; s = 0; travelSign = 1; + orientation = 1; + junctionRoutes.clear(); clearArm(); routeJunctionId = null; takeBranch = false; @@ -630,6 +624,8 @@ public void unbind() { car.getTrainHandler().splineId = null; car.getTrainHandler().s = 0; car.getTrainHandler().travelSign = 1; + car.getTrainHandler().orientation = 1; + car.getTrainHandler().junctionRoutes.clear(); } } @@ -640,6 +636,7 @@ public boolean bind(TrackSpline spline) { Location loc = v.getEntity().getLocation(); splineId = spline.getId(); s = spline.nearestS(loc.getX(), loc.getY(), loc.getZ()); + orientation = 1; travelSign = facingSign(loc, spline.sampleAt(s)); applyPose(v, spline.sampleAt(s)); placeLoadedCars(); @@ -745,9 +742,6 @@ public void splineTick() { return; } double speed = v.getAccessPanel() == null ? 0 : v.getAccessPanel().getSpeed(); - if (speed < 0) { - clearArm(); - } if (TrackSplineMotion.stopped(speed)) { animateMove(Direction.STILL); if (keepBound()) { @@ -764,7 +758,7 @@ public void splineTick() { still(); return; } - travelSign = speed < 0 ? -1 : 1; + travelSign = (speed < 0 ? -1 : 1) * orientation; splineStep(speed); } @@ -774,7 +768,10 @@ public void placeLoadedCars() { checkLoadedPosition = false; followTrackUnderEntity(); } - applyPlacements(planCars()); + Map used = new LinkedHashMap<>(); + List placements = planCars(used); + retainRoutes(placements, used); + applyPlacements(placements); } /** @@ -794,7 +791,7 @@ private void followTrackUnderEntity() { Float facing = savedModelYaw(); if (current != null) { TrackPose saved = current.sampleAt(s); - if (onTrack(saved, at) && (facing == null || facesAlong(facing, saved))) { + if (onTrack(saved, at) && (facing == null || facesAlong(facing, TrainRoute.facing(saved, orientation)))) { return; } } @@ -807,120 +804,67 @@ private void followTrackUnderEntity() { moveTo(registry, match); } - private record CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign, + private record CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign, int orientation, double missingSpacing, TrackPose pose, TrackPose[] bogieRails, double[] missingRail) { + Position position() { return new Position(spline.getId(), s, orientation); } } - // The junction route the consist is placed along, so bogies can straddle a junction. - private record Route(TrackRegistry registry, boolean takeBranch, UUID stemId, UUID branchId, - double junctionS, int facingSign, double stemLength, boolean stemLoop, double branchLength) { - /** Distance a support lies beyond real track; branch tips do not reconnect to the stem. */ - double missingRail(UUID splineId, double s, double offset) { - TrackSpline spline = registry.get(splineId).orElse(null); - if (spline == null) { - return Math.abs(offset); - } - if (splineId.equals(branchId) && s + offset > spline.length()) { - return s + offset - spline.length(); - } - // The leading wheels can reach a thrown turnout before the car's centre - // selects it. In particular, a turnout at the stem end still supplies rail. - if (!splineId.equals(branchId) && Math.abs(offset) > 1e-9) { - int direction = offset > 0 ? 1 : -1; - TrackJunction first = null; - double nearest = Double.POSITIVE_INFINITY; - for (TrackJunction junction : registry.junctionsOn(splineId)) { - boolean selected = takeBranch && junction.branchSplineId != null - && junction.branchSplineId.equals(branchId); - double distance = TrackJunctionTravel.ahead(s, junction.s, direction, - spline.isLoop(), spline.length()); - if ((selected || junction.thrown) && junction.facingSign == direction - && junction.branchSplineId != null && distance >= -1e-9 - && distance <= Math.abs(offset) && distance < nearest) { - first = junction; - nearest = distance; - } - } - if (first != null) { - TrackSpline branch = registry.get(first.branchSplineId).orElse(null); - if (branch != null) { - return Math.max(0, Math.abs(offset) - nearest - branch.length()); - } - } - } - return TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1, - Math.abs(offset), takeBranch || splineId.equals(branchId), - stemId == null ? splineId : stemId, branchId, junctionS, - facingSign, stemLength, stemLoop, branchLength).missingSpacing; - } - - /** The rail at {@code offset} along the track from {@code s}, following the route. */ - TrackPose rail(UUID splineId, double s, double offset) { - TrackJunctionTravel.Pose at = TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1, - Math.abs(offset), takeBranch, stemId == null ? splineId : stemId, branchId, junctionS, - facingSign, stemLength, stemLoop, branchLength); - TrackSpline spline = at.splineId == null ? null : registry.get(at.splineId).orElse(null); - if (spline == null) { - return null; - } - if (at.missingSpacing <= 1e-9) { - return spline.sampleAt(at.s); - } - // Past the end of the route: carry on straight off that end. - double outwards = at.s <= 1e-9 ? -1 : 1; - return Bogies.rail(spline, at.s + outwards * at.missingSpacing); + private Position position() { return new Position(splineId, s, orientation); } + + private TrainRoute route() { return new TrainRoute(VehicleFramework.getTrackRegistry(), junctionRoutes); } + + // Model offsets remain in the body frame even when the body faces -s. + private double[] supportOffsets() { + List offsets = new ArrayList<>(); + if (onBogies()) { + for (double offset : bogies.offsets()) { offsets.add(offset); } } + for (String name : wheelBones) { + try { + ActiveModel model = v.getModel(); + offsets.add((double) model.getBone(name).orElseThrow().getBlueprintBone() + .getRotatedGlobalPosition().z() * model.getScale().z()); + } catch (RuntimeException notLoaded) { } + } + return offsets.isEmpty() ? new double[] {0} : offsets.stream().mapToDouble(Double::doubleValue).toArray(); } - // A bogie car's two rails along the route, or null to place it as a rigid car. - private TrackPose[] bogieRails(Route route, UUID onSpline, double at) { - if (!onBogies() || route == null) { - return null; - } + private TrackPose[] bogieRails(TrainRoute route, Position at) { + if (!onBogies()) { return null; } double[] offsets = bogies.offsets(); TrackPose[] rails = new TrackPose[offsets.length]; for (int i = 0; i < offsets.length; i++) { - rails[i] = route.rail(onSpline, at, offsets[i]); - if (rails[i] == null) { - return null; - } + rails[i] = route.rail(at, offsets[i]); + if (rails[i] == null) { return null; } } return rails; } - // Resolve the outer axle bones in model space, including the current model scale. - // Older configurations retain bogie support checks, or centre checks for rigid cars. - private double[] missingWheelRail(Route route, UUID onSpline, double at) { + private double[] missingWheelRail(TrainRoute route, Position at, Map used) { double[] missing = new double[2]; - if (route == null) { - return missing; - } - if (onBogies()) { - for (double offset : bogies.offsets()) { - int end = offset < 0 ? 0 : 1; - missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset)); - } - } - for (String name : wheelBones) { - try { - ActiveModel model = v.getModel(); - double offset = model.getBone(name).orElseThrow().getBlueprintBone() - .getRotatedGlobalPosition().z() * model.getScale().z(); - int end = offset < 0 ? 0 : 1; - missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset)); - } catch (RuntimeException notLoaded) { - // The model or this bone is not available yet. - } + for (double offset : supportOffsets()) { + TrainRoute.Walk walked = route.walk(at, offset); + if (used != null) { used.putAll(walked.junctions()); } + int end = offset < 0 ? 0 : 1; + missing[end] = Math.max(missing[end], walked.missing()); } return missing; } + private boolean onBogies() { return bogies != null && bogies.isReady(); } + private void applyPlacements(List placements) { for (CarPlacement placement : placements) { TrainHandler train = placement.vehicle.getTrainHandler(); train.splineId = placement.spline.getId(); train.s = placement.s; train.travelSign = placement.sign; + train.orientation = placement.orientation; + if (train != this) { + train.junctionRoutes.clear(); + train.junctionRoutes.putAll(junctionRoutes); + train.syncRoute(); + } applyPose(placement.vehicle, placement.pose()); if (placement.bogieRails() != null) { train.bogies.follow(placement.bogieRails(), placement.pose()); @@ -928,101 +872,119 @@ private void applyPlacements(List placements) { } } - private boolean onBogies() { - return bogies != null && bogies.isReady(); - } + private List planCars() { return planCars(null); } - private List planCars() { + private List planCars(Map used) { List placements = new ArrayList<>(); - TrackSpline spline = boundSpline(); - if (spline == null) { - return placements; - } TrackRegistry registry = VehicleFramework.getTrackRegistry(); - TrackJunction route = registry == null ? null : routeJunction(); - UUID stemId = route == null ? null : route.stemSplineId; - UUID branchId = route == null ? null : route.branchSplineId; - double junctionS = route == null ? 0 : route.s; - int facingSign = route == null ? 1 : route.facingSign; - TrackSpline stem = stemId == null || registry == null ? null : registry.get(stemId).orElse(null); - TrackSpline branch = branchId == null || registry == null ? null : registry.get(branchId).orElse(null); - double stemLength = stem == null ? spline.length() : stem.length(); - boolean stemLoop = stem != null ? stem.isLoop() : spline.isLoop(); - double branchLength = branch == null ? 0 : branch.length(); - Route along = registry == null ? null : new Route(registry, takeBranch && route != null, - stemId, branchId, junctionS, facingSign, stemLength, stemLoop, branchLength); - TrackPose[] locoRails = bogieRails(along, splineId, s); - placements.add(new CarPlacement(v, spline, s, travelSign, 0, - locoRails == null ? spline.sampleAt(s) : bogies.bodyPose(locoRails), locoRails, - missingWheelRail(along, splineId, s))); - if (registry == null) { - return placements; - } - UUID parentSpline = splineId; - double parentS = s; - // Models face the +s tangent even in reverse. Couplers stay on that - // physical side; using travelSign here swaps the cars across the loco. - int parentPlacementSign = 1; - ActiveVehicle parentCar = v; - ActiveVehicle car = child; + if (registry == null || boundSpline() == null) { return placements; } + TrainRoute route = route(); + Position at = position(); + ActiveVehicle car = v; + CarPlacement parent = null; + double missing = 0; + int bodyTravel = travelSign * orientation; while (car != null) { - TrainHandler parentTrain = parentCar.getTrainHandler(); - TrainHandler carTrain = car.getTrainHandler(); - double gap = spacing(parentTrain, carTrain); - TrackJunctionTravel.Pose pose = TrackJunctionTravel.rewind( - parentSpline, - parentS, - parentPlacementSign, - gap, - takeBranch && route != null, - stemId == null ? splineId : stemId, - branchId, - junctionS, - facingSign, - stemLength, - stemLoop, - branchLength); - int carTravelSign; - int carPlacementSign = 1; - if (branchId != null && branchId.equals(pose.splineId)) { - carTravelSign = 1; - } else { - carTravelSign = travelSign; - if (takeBranch && route != null && splineId != null && splineId.equals(branchId)) { - carTravelSign = facingSign; - carPlacementSign = facingSign; - } + TrainHandler train = car.getTrainHandler(); + TrackSpline spline = registry.get(at.splineId()).orElse(null); + if (spline == null) { return List.of(); } + TrackPose[] rails = train.bogieRails(route, at); + TrackPose pose = rails == null ? TrainRoute.facing(spline.sampleAt(at.s()), at.orientation()) + : train.bogies.bodyPose(rails); + if (parent != null && rails == null && parent.vehicle.getTrainHandler().canHaveAttached() + && train.isAttachable() && missing <= 1e-9) { + try { + pose = train.getFront().coupledPose(pose, + parent.vehicle.getTrainHandler().getBack().positionAt(parent.pose())); + } catch (RuntimeException notLoaded) { } } - TrackSpline carSpline = pose.splineId == null ? null : registry.get(pose.splineId).orElse(null); - if (carSpline != null) { - TrackPose[] rails = carTrain.bogieRails(along, pose.splineId, pose.s); - TrackPose carPose = rails == null ? carSpline.sampleAt(pose.s) : carTrain.bogies.bodyPose(rails); - // A car on bogies follows its own rails; its couplers swing to meet, as real ones do. - if (rails == null && parentTrain.canHaveAttached() && carTrain.isAttachable() - && pose.missingSpacing <= 1e-9) { - // Arc spacing locates the car on the route; rigid couplers must meet - // in world space. A separate tangent at each centre opens a gap on bends. - TrackPose parentPose = placements.get(placements.size() - 1).pose(); - try { - carPose = carTrain.getFront().coupledPose(carPose, parentTrain.getBack().positionAt(parentPose)); - } catch (RuntimeException ignored) { - // Keep the sampled pose until connector blueprints and model transforms load. - } - } - placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails, - carTrain.missingWheelRail(along, pose.splineId, pose.s))); - } else { - return List.of(); + CarPlacement placement = new CarPlacement(car, spline, at.s(), bodyTravel * at.orientation(), + at.orientation(), missing, pose, rails, train.missingWheelRail(route, at, used)); + placements.add(placement); + parent = placement; + car = train.child; + if (car != null) { + TrainRoute.Walk walk = route.walk(at, -spacing(train, car.getTrainHandler())); + if (used != null) { used.putAll(walk.junctions()); } + at = walk.position(); + missing = walk.missing(); } - parentSpline = pose.splineId; - parentS = pose.s; - parentCar = car; - parentPlacementSign = carPlacementSign; - car = carTrain.child; } return placements; } + private Position leadingPosition(List cars, int direction) { + if (cars.isEmpty()) { return null; } + CarPlacement car = direction > 0 ? cars.get(0) : cars.get(cars.size() - 1); + double offset = 0; + for (double wheel : car.vehicle.getTrainHandler().supportOffsets()) { + offset = direction > 0 ? Math.max(offset, wheel) : Math.min(offset, wheel); + } + return route().walk(car.position(), offset).position(); + } + + /** Retain each occupied switch, including adjacent switches spanned by one consist. */ + private void retainRoutes(List cars, Map used) { + TrackRegistry registry = VehicleFramework.getTrackRegistry(); + if (registry == null) { return; } + Map occupied = new LinkedHashMap<>(); + // Missing cars may still straddle points. Keep their snapshot until the + // links resolve or are explicitly removed, then trim it normally again. + if ((pendingParent != null && !v.hasParent()) || cars.stream().anyMatch(car -> { + TrainHandler train = car.vehicle.getTrainHandler(); + return train.pendingChild != null && train.child == null; + })) { + occupied.putAll(junctionRoutes); + } + used.forEach(occupied::putIfAbsent); + for (CarPlacement car : cars) { + TrainHandler train = car.vehicle.getTrainHandler(); + double low = 0; + double high = 0; + for (double offset : train.supportOffsets()) { + low = Math.min(low, offset * car.orientation); + high = Math.max(high, offset * car.orientation); + } + for (TrackJunction junction : registry.junctionsOn(car.spline.getId())) { + double padding = junctionRoutes.containsKey(junction.id) ? reach(train) : 0; + boolean ahead = TrackJunctionTravel.inArmWindow(car.s, junction.s, 1, + car.spline.isLoop(), car.spline.length(), Math.max(high, padding) + 1e-9); + boolean behind = TrackJunctionTravel.inArmWindow(car.s, junction.s, -1, + car.spline.isLoop(), car.spline.length(), Math.max(-low, padding) + 1e-9); + if (ahead || behind) { + occupied.putIfAbsent(junction.id, junctionRoutes.getOrDefault(junction.id, false)); + } + } + TrackJunction branch = registry.junctionByBranch(car.spline.getId()).orElse(null); + double padding = branch != null && junctionRoutes.containsKey(branch.id) ? reach(train) : 0; + if (branch != null && car.s - Math.max(-low, padding) <= branch.turnoutEndS + 1e-9) { + occupied.put(branch.id, true); + } + } + junctionRoutes.clear(); + junctionRoutes.putAll(occupied); + syncRoute(); + } + + private void syncRoute() { + routeJunctionId = junctionRoutes.isEmpty() ? null : junctionRoutes.keySet().iterator().next(); + takeBranch = routeJunctionId != null && junctionRoutes.get(routeJunctionId); + } + + public boolean holdsJunction(UUID id) { + return v != null && !v.hasParent() && boundSpline() != null && junctionRoutes.containsKey(id); + } + + public static boolean junctionOccupied(UUID id) { + VehicleManager vehicles = VehicleFramework.getVehicleManager(); + if (vehicles != null) { + for (ActiveVehicle vehicle : vehicles.get().values()) { + if (vehicle.isTrain() && vehicle.getTrainHandler().holdsJunction(id)) { return true; } + } + } + return false; + } + private boolean keepBound() { if (splineId == null) { return false; @@ -1109,8 +1071,8 @@ private record TrackMatch(TrackSpline spline, double s) { /** * The closest point on these tracks that counts as the same place, - * preferring the current track. With {@code facing}, only track whose +s - * runs the way the model faces qualifies; the consist cannot face -s. + * preferring the current track. With {@code facing}, the saved body orientation + * must still match the rail; reversing an unloaded train's track is rejected. */ private TrackMatch nearestTrack(Collection candidates, TrackPose was, Float facing) { TrackMatch best = null; @@ -1118,7 +1080,7 @@ private TrackMatch nearestTrack(Collection candidates, TrackPose wa for (TrackSpline candidate : candidates) { double candidateS = candidate.nearestS(was.x, was.y, was.z); TrackPose at = candidate.sampleAt(candidateS); - if (!onTrack(at, was) || (facing != null && !facesAlong(facing, at))) { + if (!onTrack(at, was) || (facing != null && !facesAlong(facing, TrainRoute.facing(at, orientation)))) { continue; } double d = Math.pow(at.x - was.x, 2) + Math.pow(at.y - was.y, 2) + Math.pow(at.z - was.z, 2); @@ -1133,7 +1095,7 @@ private TrackMatch nearestTrack(Collection candidates, TrackPose wa /** * World yaw the model faced when saved. applyPose turns the bone to the - * track's +s heading relative to the entity, so undo that. Null without a rotator. + * body's heading relative to the entity, so undo that. Null without a rotator. */ private Float savedModelYaw() { if (v == null || v.getEntity() == null || v.getBehaviourHandler() == null) { @@ -1163,6 +1125,7 @@ private void moveTo(TrackRegistry registry, TrackMatch match) { if (!target.equals(splineId) && registry != null && !routeTouches(registry, target)) { routeJunctionId = null; takeBranch = false; + junctionRoutes.clear(); } PersistenceLog.append("RETRACK " + PersistenceLog.vehicle(v) + " from=" + splineId + "@" + s + " to=" + target + "@" + match.s()); @@ -1171,12 +1134,13 @@ private void moveTo(TrackRegistry registry, TrackMatch match) { } private boolean routeTouches(TrackRegistry registry, UUID trackId) { - if (routeJunctionId == null) { - return false; + for (UUID id : junctionRoutes.keySet()) { + TrackJunction route = registry.getJunction(id).orElse(null); + if (route != null && (trackId.equals(route.stemSplineId) || trackId.equals(route.branchSplineId))) { + return true; + } } - TrackJunction route = registry.getJunction(routeJunctionId).orElse(null); - return route != null - && (trackId.equals(route.stemSplineId) || trackId.equals(route.branchSplineId)); + return false; } /** @@ -1225,71 +1189,72 @@ private boolean tryBindOrKeep() { private static final long STOPPED_SHOW_MS = 2000; private long stoppedShownMs; - private record StepState(UUID splineId, double s, int travelSign, UUID routeJunctionId, - boolean takeBranch, UUID armedJunctionId, TrackJunction.Side armedSide) { - } + private record StepState(UUID splineId, double s, int travelSign, int orientation, + Map routes, UUID armedJunctionId, TrackJunction.Side armedSide) { } private StepState stepState() { - return new StepState(splineId, s, travelSign, routeJunctionId, takeBranch, armedJunctionId, armedSide); + return new StepState(splineId, s, travelSign, orientation, new LinkedHashMap<>(junctionRoutes), + armedJunctionId, armedSide); } private void restoreStep(StepState state) { splineId = state.splineId; s = state.s; travelSign = state.travelSign; - routeJunctionId = state.routeJunctionId; - takeBranch = state.takeBranch; + orientation = state.orientation; + junctionRoutes.clear(); + junctionRoutes.putAll(state.routes); + syncRoute(); armedJunctionId = state.armedJunctionId; armedSide = state.armedSide; } - private void splineStep(double ds) { - if (boundSpline() == null) { - unbind(); - still(); - return; - } - // Plan short steps for every car. Commit only the last clear plan, so a - // blocked carriage cannot leave the locomotive moving independently. - List accepted = planCars(); + private void splineStep(double distance) { + if (boundSpline() == null) { unbind(); still(); return; } + Map used = new LinkedHashMap<>(); + List accepted = planCars(used); + retainRoutes(accepted, used); List afterMove = new ArrayList<>(); - // Block shapes looked up for this tick only; blocks can change between ticks. Map> shapes = new HashMap<>(); - int steps = Math.max(1, (int) Math.ceil(Math.abs(ds) / 0.25)); - double step = ds / steps; + int steps = Math.max(1, (int) Math.ceil(Math.abs(distance) / 0.25)); + double step = distance / steps; + int direction = distance < 0 ? -1 : 1; double moved = 0; boolean blocked = false; boolean trackEnd = false; for (int i = 0; i < steps; i++) { StepState before = stepState(); - TrackSpline spline = boundSpline(); - TrackAdvance advance = spline.advance(s, step); - TrackRegistry registry = VehicleFramework.getTrackRegistry(); - List junctionEvents = new ArrayList<>(); - if (!applyJunctionStep(registry, spline, s, advance.s, step, junctionEvents)) { - s = advance.s; - } - List next = planCars(); + Position lead = leadingPosition(accepted, direction); + TrainRoute.Walk leading = route().walk(lead, step, true, false); + leading.junctions().forEach(junctionRoutes::putIfAbsent); + TrainRoute.Walk advance = route().walk(position(), step, false, true); + advance.junctions().forEach(junctionRoutes::putIfAbsent); + splineId = advance.position().splineId(); + s = advance.position().s(); + orientation = advance.position().orientation(); + travelSign = direction * orientation; + syncRoute(); + used.clear(); + List next = planCars(used); if (!clearStep(accepted, next, shapes)) { restoreStep(before); blocked = true; trackEnd = compressesConsist(accepted, next) || losesWheelSupport(accepted, next); break; } - trackEnd = reachesTrackEnd(accepted, next); - accepted = next; - afterMove.addAll(junctionEvents); - if (splineId.equals(before.splineId) && Math.abs(s - before.s) < 1e-9) { - blocked = true; - trackEnd = !spline.isLoop(); - break; - } - moved += Math.abs(step); - if (trackEnd || advance.stoppedAtBreak) { - blocked = true; - trackEnd = true; - break; + for (Map.Entry entry : junctionRoutes.entrySet()) { + if (!before.routes.containsKey(entry.getKey())) { + UUID id = entry.getKey(); + boolean diverge = entry.getValue(); + afterMove.add(() -> RecorderLog.junction(v, diverge, id, "leading-wheels")); + if (id.equals(armedJunctionId)) { clearArm(); } + } } + trackEnd = reachesTrackEnd(accepted, next) || advance.missing() > 1e-9 || advance.broken(); + accepted = next; + retainRoutes(accepted, used); + moved += Math.max(0, Math.abs(step) - advance.missing()); + if (trackEnd) { blocked = true; break; } } if (moved > 0) { applyPlacements(accepted); @@ -1298,17 +1263,11 @@ private void splineStep(double ds) { } if (blocked) { if (trackEnd) { - if (v.getThrottle() != null) { - v.getThrottle().setThrottle(0); - } - if (v.getAccessPanel() != null) { - v.getAccessPanel().setSpeed(0); - } + if (v.getThrottle() != null) { v.getThrottle().setThrottle(0); } + if (v.getAccessPanel() != null) { v.getAccessPanel().setSpeed(0); } } animateMove(Direction.STILL); - for (CarPlacement car : accepted) { - car.vehicle.getEntity().setVelocity(new Vector(0, 0, 0)); - } + for (CarPlacement car : accepted) { car.vehicle.getEntity().setVelocity(new Vector(0, 0, 0)); } } } @@ -1401,96 +1360,6 @@ private boolean compressesConsist(List previous, List 1e-9 && after >= before - 1e-9; } - private boolean applyJunctionStep( - TrackRegistry registry, - TrackSpline spline, - double from, - double to, - double ds, - List afterMove) { - TrackJunction asBranch = registry.junctionByBranch(splineId).orElse(null); - if (asBranch != null && !spline.isLoop() && ds < 0 && to <= 1e-6) { - TrackSpline stem = registry.get(asBranch.stemSplineId).orElse(null); - if (stem == null) { - return false; - } - splineId = stem.getId(); - s = asBranch.s; - travelSign = -asBranch.facingSign; - routeJunctionId = asBranch.id; - takeBranch = true; - return true; - } - if (asBranch != null) { - return false; - } - for (TrackJunction junction : registry.junctionsOn(splineId)) { - if (junction.branchSplineId == null) { - continue; - } - if (!TrackJunctionTravel.crosses(from, to, junction.s, travelSign, spline.isLoop(), spline.length())) { - continue; - } - if (!TrackJunctionTravel.facing(travelSign, junction.facingSign)) { - continue; - } - TrackJunction live = registry.getJunction(junction.id).orElse(junction); - boolean diverge = live.thrown; - String reason = diverge ? "switch-diverge" : "switch-through"; - if (armedJunctionId != null && armedJunctionId.equals(junction.id)) { - clearArm(); - } - routeJunctionId = junction.id; - takeBranch = diverge; - String detail = reason - + " thrown=" - + live.thrown - + " frog=" + live.side.name() - + " facing=" + junction.facingSign - + " travel=" + travelSign; - afterMove.add(() -> { - if (RecorderLog.throttle("junc:" + v.getUUID() + ":" + junction.id, 2000)) { - RecorderLog.junction(v, diverge, junction.id, detail); - if (diverge) { - tellCaptain("Junction: diverge (" + reason.replace('-', ' ') + ")"); - } else { - tellCaptain("Junction: through (" + reason.replace('-', ' ') + ")"); - } - } - }); - if (!diverge) { - continue; - } - TrackSpline branch = registry.get(junction.branchSplineId).orElse(null); - if (branch == null) { - takeBranch = false; - continue; - } - splineId = branch.getId(); - s = 0; - travelSign = 1; - return true; - } - return false; - } - - private TrackJunction routeJunction() { - TrackRegistry registry = VehicleFramework.getTrackRegistry(); - if (registry == null) { - return null; - } - if (routeJunctionId != null) { - TrackJunction stored = registry.getJunction(routeJunctionId).orElse(null); - if (stored != null) { - return stored; - } - } - if (splineId == null) { - return null; - } - return registry.junctionByBranch(splineId).orElse(null); - } - private void maybeClack(double ds) { fxTraveled += Math.abs(ds); if (fxTraveled < Cache.trackFxSoundInterval) { diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/ConsistDataTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/ConsistDataTest.java index 1159119..6c19672 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/database/ConsistDataTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/database/ConsistDataTest.java @@ -10,6 +10,23 @@ class ConsistDataTest { + @Test + void orientationAndMultipleOccupiedJunctionsRoundTripWithoutReinterpretingOldReverseSaves() { + ConsistData old = new ConsistData(null, null, "track", 12d, -1, "first", true); + JSONObject oldJson = new JSONObject(); + old.put(oldJson); + assertEquals(1, ConsistData.fromJson(oldJson).getOrientation()); + assertFalse(oldJson.containsKey("orientation")); + ConsistData current = new ConsistData(null, null, "track", 12d, 1, "first", true, + -1, java.util.Map.of("first", true, "second", false)); + JSONObject json = new JSONObject(); + current.put(json); + ConsistData loaded = ConsistData.fromJson(json); + assertEquals(-1, loaded.getOrientation()); + assertEquals(1, loaded.getTravelSign()); + assertEquals(current.getJunctions(), loaded.getJunctions()); + } + @Test void put_omitsKeysWhenUnbound() { JSONObject json = new JSONObject(); diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/ThrottleTapeTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/ThrottleTapeTest.java index 746c960..98ca85f 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/tracks/ThrottleTapeTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/ThrottleTapeTest.java @@ -11,6 +11,22 @@ class ThrottleTapeTest { + @Test + void samplesConvertBodyOrientationWithoutConfusingBrakingWithReverseTravel() { + java.util.UUID stem = java.util.UUID.randomUUID(); + java.util.UUID branch = java.util.UUID.randomUUID(); + ThrottleTape tape = new ThrottleTape(stem.toString()); + // Body faces +s and rolls backwards while positive throttle brakes it. + tape.tryAppend(20, -1, 40, stem.toString(), null, 1); + // After the turnout, the body faces -s and the same reverse motion rolls +s. + tape.tryAppend(5, 1, 40, branch.toString(), null, -1); + ThrottleTape loaded = ThrottleTape.fromJson(tape.toJson()); + assertEquals(40, loaded.targetWithDwell(20, -1, null, stem, 1)); + assertEquals(-40, loaded.targetWithDwell(20, -1, null, stem, -1)); + assertEquals(40, loaded.targetWithDwell(5, 1, null, branch, -1)); + assertEquals(-40, loaded.targetWithDwell(5, 1, null, branch, 1)); + } + @Test void emptyTape_lookupZero() { assertEquals(0, ThrottleTape.lookup(List.of(), 10.0, 1)); diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrainRouteTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrainRouteTest.java new file mode 100644 index 0000000..23e7106 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrainRouteTest.java @@ -0,0 +1,76 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class TrainRouteTest { + + @TempDir Path directory; + + @ParameterizedTest + @CsvSource({"1, 1", "1, -1", "-1, 1", "-1, -1"}) + void crossingAndRetracingConservesDistanceAndPhysicalFacing(int facing, int bodyDirection) { + TrackRegistry registry = network(facing, false); + TrackJunction junction = registry.junctionsOn(stem).get(0); + TrainRoute route = new TrainRoute(registry, Map.of(junction.id, true)); + TrainRoute.Position start = new TrainRoute.Position(stem, 50 - facing * 0.1, facing * bodyDirection); + TrainRoute.Walk crossed = route.walk(start, bodyDirection * 2, false, true); + assertEquals(branch, crossed.position().splineId()); + assertEquals(1.9, crossed.position().s(), 1e-9); + assertEquals(bodyDirection, crossed.position().orientation()); + assertEquals(0, crossed.missing()); + TrainRoute.Walk back = route.walk(crossed.position(), -bodyDirection * 2, false, true); + assertEquals(start.splineId(), back.position().splineId()); + assertEquals(start.s(), back.position().s(), 1e-9); + assertEquals(start.orientation(), back.position().orientation()); + } + + @Test + void branchTipIsAnEndAndDoesNotConnectToTheStem() { + TrackRegistry registry = network(-1, false); + TrainRoute route = new TrainRoute(registry, Map.of()); + TrainRoute.Walk result = route.walk(new TrainRoute.Position(branch, 29, -1), -5); + assertEquals(branch, result.position().splineId()); + assertEquals(30, result.position().s()); + assertEquals(4, result.missing()); + } + + @Test + void chosenThroughRouteWinsOverLaterSwitchChangeAndLoopSeam() { + TrackRegistry registry = network(-1, true); + TrackJunction junction = registry.junctionsOn(stem).get(0); + registry.setThrown(junction.id, true); + TrainRoute route = new TrainRoute(registry, Map.of(junction.id, false)); + TrainRoute.Walk result = route.walk(new TrainRoute.Position(stem, 51, 1), -52, true, false); + assertEquals(stem, result.position().splineId()); + assertEquals(registry.get(stem).orElseThrow().length() - 1, result.position().s(), 1e-9); + assertEquals(false, result.junctions().get(junction.id)); + } + + private UUID stem; + private UUID branch; + + private TrackRegistry network(int facing, boolean loop) { + TrackStore store = new TrackStore(directory.toFile()); + stem = UUID.randomUUID(); + branch = UUID.randomUUID(); + store.save(TrackSpline.fromPoints(stem, "world", loop, + List.of(new double[]{0, 64, 0}, new double[]{0, 64, 100}))); + store.save(TrackSpline.fromPoints(branch, "world", false, + List.of(new double[]{0, 64, 50}, new double[]{0, 64, 50 + facing * 30}))); + store.saveJunction("world", new TrackJunction(UUID.randomUUID(), stem, 50, + facing, TrackJunction.Side.LEFT, branch, false)); + TrackRegistry registry = new TrackRegistry(directory.toFile()); + registry.loadFromDisk(); + return registry; + } +} 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 fd9bc20..92e2d2f 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java @@ -537,7 +537,7 @@ void blockedJunctionEntryDoesNotCommitRouteOrTeleportAnyCar() { loco.splineTick(); assertEquals(branch.getId(), loco.getSplineId()); assertEquals(junction.id.toString(), loco.toConsistData().getJunctionId()); - assertEquals(40, loco.getChild().getTrainHandler().getS(), 1e-8); + assertEquals(40.1, loco.getChild().getTrainHandler().getS(), 1e-8); } @Test @@ -589,7 +589,8 @@ void carriageCrossingBranchStartDoesNotResetThrottle() { loco.v.getThrottle().setThrottle(-100); loco.v.getAccessPanel().setSpeed(-0.1); loco.splineTick(); - assertEquals(0, loco.getChild().getTrainHandler().getS(), 1e-8); + assertEquals(stem.getId(), loco.getChild().getTrainHandler().getSplineId()); + assertEquals(50, loco.getChild().getTrainHandler().getS(), 1e-8); assertEquals(-100, loco.v.getThrottle().getCurrent()); loco.splineTick(); assertEquals(stem.getId(), loco.getChild().getTrainHandler().getSplineId()); @@ -868,6 +869,311 @@ void digTargetCoversTheEdgesItRemoves() { assertEquals(track.getId(), target.spline().getId()); } + @ParameterizedTest + @CsvSource({"1, false", "-1, false", "1, true", "-1, true"}) + void wholeTrainTraversesEitherFacingTurnoutWithoutJumping(int facing, boolean reverse) { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, facing, false); + int orientation = reverse ? -facing : facing; + double start = 50 - facing * (reverse ? 38 : 8); + TrainHandler loco = wheeledConsist(stem, start); + loco.getChild().getTrainHandler().getChild().getTrainHandler().setChild(car(List.of("axle_front", "axle_back")).v); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), start, + facing, null, null, orientation, java.util.Map.of())); + loco.placeLoadedCars(); + registry.onJunctionOccupied(loco::holdsJunction); + List rotations = captureRotations(loco); + double speed = reverse ? -0.25 : 0.25; + loco.v.getAccessPanel().setSpeed(speed); + // The current speed determines approach direction even when the throttle is braking. + loco.v.getThrottle().setThrottle(reverse ? 100 : -100); + loco.holdJunction(TrackJunction.Side.LEFT); + assertTrue(registry.getJunction(junction.id).orElseThrow().thrown); + int ticks = reverse ? 232 : 272; + boolean sawLocked = false; + for (int tick = 0; tick < ticks; tick++) { + List before = carLocations(loco); + loco.splineTick(); + assertContinuousCoupled(loco, rotations, before, 1.1); + if (loco.holdsJunction(junction.id)) { + sawLocked = true; + assertFalse(registry.setThrown(junction.id, false), "An occupied switch must not move"); + } + } + assertTrue(sawLocked); + assertFalse(loco.holdsJunction(junction.id), "Release the points after the whole train clears"); + int i = 0; + for (TrainHandler car : cars(loco)) { + assertEquals(junction.branchSplineId, car.getSplineId()); + assertEquals(reverse ? -1 : 1, car.getOrientation()); + assertEquals(reverse ? 20 + i * 10 : 60 - i * 10, car.getS(), 1e-6); + i++; + } + assertTrue(registry.setThrown(junction.id, false)); + // Leaving the branch is a trailing move: return along the same connected rails + // even if another train has since set the turnout through. + for (int tick = 0; tick < ticks; tick++) { + List before = carLocations(loco); + loco.v.getAccessPanel().setSpeed(-speed); + loco.splineTick(); + assertContinuousCoupled(loco, rotations, before, 1.1); + } + assertEquals(stem.getId(), loco.getSplineId()); + assertEquals(start, loco.getS(), 1e-6); + assertEquals(orientation, loco.getOrientation()); + assertFalse(loco.holdsJunction(junction.id)); + } + + @ParameterizedTest + @ValueSource(ints = {1, -1}) + void reverseMidTurnoutAndReloadRetracesTheChosenRoute(int facing) { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, facing, true); + double start = 50 - facing * 28; + TrainHandler loco = wheeledConsist(stem, start); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), start, + facing, null, null, -facing, java.util.Map.of())); + loco.placeLoadedCars(); + List rotations = captureRotations(loco); + registry.onJunctionOccupied(loco::holdsJunction); + List initial = carLocations(loco); + for (int tick = 0; tick < 80; tick++) { + List before = carLocations(loco); + loco.v.getAccessPanel().setSpeed(-0.25); + loco.splineTick(); + assertContinuousCoupled(loco, rotations, before, 1.1); + } + assertTrue(loco.holdsJunction(junction.id)); + assertEquals(stem.getId(), loco.getSplineId(), "Engine has not entered yet"); + assertEquals(junction.branchSplineId, cars(loco).get(2).getSplineId(), "Leading car already diverged"); + List saved = carLocations(loco); + for (TrainHandler car : cars(loco)) { + org.json.simple.JSONObject json = new org.json.simple.JSONObject(); + car.toConsistData().put(json); + car.applyConsist(ConsistData.fromJson(json)); + } + loco.v.getAccessPanel().setSpeed(0); + loco.splineTick(); + for (int i = 0; i < saved.size(); i++) { assertEquals(saved.get(i), carLocations(loco).get(i)); } + for (int tick = 0; tick < 80; tick++) { + List before = carLocations(loco); + loco.v.getAccessPanel().setSpeed(0.25); + loco.splineTick(); + assertContinuousCoupled(loco, rotations, before, 1.1); + } + for (int i = 0; i < initial.size(); i++) { + assertEquals(0, initial.get(i).toVector().distance(carLocations(loco).get(i).toVector()), 1e-6); + } + assertFalse(loco.holdsJunction(junction.id)); + } + + @Test + void reversingTrainRetainsTwoAdjacentSwitchesUntilEachClears() { + TrackSpline stem = straightTrack(false); + TrackJunction first = smoothTurnout(stem, 70, -1, false); + TrackJunction second = smoothTurnout(stem, 55, -1, true); + TrainHandler loco = wheeledConsist(stem, 98); + registry.onJunctionOccupied(loco::holdsJunction); + List rotations = captureRotations(loco); + boolean spannedBoth = false; + for (int tick = 0; tick < 244; tick++) { + List before = carLocations(loco); + loco.v.getAccessPanel().setSpeed(-0.25); + loco.splineTick(); + assertContinuousCoupled(loco, rotations, before, 1.1); + spannedBoth |= loco.holdsJunction(first.id) && loco.holdsJunction(second.id); + } + assertTrue(spannedBoth); + for (TrainHandler car : cars(loco)) { assertEquals(second.branchSplineId, car.getSplineId()); } + assertFalse(loco.holdsJunction(first.id)); + assertFalse(loco.holdsJunction(second.id)); + } + + @ParameterizedTest + @ValueSource(ints = {1, -1}) + void oldOrOppositeFacingSavesStillRejectTrackReversedWhileUnloaded(int orientation) { + TrackSpline track = straightTrack(false); + TrainHandler loco = car(); + loco.applyConsist(new ConsistData(null, null, track.getId().toString(), 50d, + -1, null, null, orientation, java.util.Map.of())); + loco.placeLoadedCars(); + savedBoneYaw(loco, orientation < 0 ? 180 : 0); + inWorld(loco, "world"); + loco.applyConsist(loco.toConsistData()); + loco.splineTick(); + assertTrue(loco.isBound(), "A saved -s orientation is valid on unchanged track"); + List points = new ArrayList<>(track.xyz()); + Collections.reverse(points); + registry.replace(TrackSpline.fromPoints(track.getId(), "world", false, points)); + loco.applyConsist(loco.toConsistData()); + loco.splineTick(); + assertFalse(loco.isBound(), "Reversing the underlying track still invalidates the saved pose"); + } + + @ParameterizedTest + @CsvSource({"1, 2, -4", "-1, 4, -2"}) + void asymmetricWheelsDoNotFreezeThroughBeforeLeadingWheelArrives(int facing, float front, float back) { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, facing, true); + TrainHandler loco = car(List.of("axle_front", "axle_back")); + connector(loco.v, loco.v.getModel(), "axle_front", front); + connector(loco.v, loco.v.getModel(), "axle_back", back); + loco.setSplineId(stem.getId()); + loco.setS(50 - facing * 5); + loco.placeLoadedCars(); + loco.v.getAccessPanel().setSpeed(facing * 0.25); + for (int tick = 0; tick < 24; tick++) { loco.splineTick(); } + assertEquals(junction.branchSplineId, loco.getSplineId()); + assertEquals(1, loco.getS(), 1e-6); + } + + @ParameterizedTest + @ValueSource(ints = {1, -1}) + void routeSurvivesWhileUnloadedTailWaitsToRelink(int facing) { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, facing, true); + double start = 50 - facing * 28; + TrainHandler loco = wheeledConsist(stem, start); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), start, + facing, null, null, -facing, java.util.Map.of())); + loco.placeLoadedCars(); + loco.v.getAccessPanel().setSpeed(-0.25); + for (int tick = 0; tick < 80; tick++) { loco.splineTick(); } + TrainHandler child = loco.getChild().getTrainHandler(); + TrainHandler tail = child.getChild().getTrainHandler(); + assertEquals(junction.branchSplineId, tail.getSplineId()); + Location tailAt = tail.v.getEntity().getLocation(); + loco.setPendingChild(child.v.getUUID()); + loco.setChild(null); + loco.v.getAccessPanel().setSpeed(0); + loco.splineTick(); + assertTrue(loco.holdsJunction(junction.id), "The unloaded tail still occupies the chosen branch"); + org.json.simple.JSONObject saved = new org.json.simple.JSONObject(); + loco.toConsistData().put(saved); + loco.applyConsist(ConsistData.fromJson(saved)); + loco.splineTick(); + assertTrue(loco.holdsJunction(junction.id)); + loco.setChild(child.v); + loco.placeLoadedCars(); + assertEquals(tailAt, tail.v.getEntity().getLocation()); + for (int tick = 0; tick < 140; tick++) { + loco.v.getAccessPanel().setSpeed(-0.25); + loco.splineTick(); + } + assertFalse(loco.holdsJunction(junction.id), "Resolved snapshots must eventually release the lock"); + assertTrue(tail.toConsistData().getJunctions().isEmpty()); + } + + @ParameterizedTest + @ValueSource(ints = {1, -1}) + void childFirstLoadKeepsTheRouteUntilItsParentReturns(int facing) { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, facing, true); + double start = 50 - facing * 28; + TrainHandler loco = wheeledConsist(stem, start); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), start, + facing, null, null, -facing, java.util.Map.of())); + loco.placeLoadedCars(); + loco.v.getAccessPanel().setSpeed(-0.25); + for (int tick = 0; tick < 80; tick++) { loco.splineTick(); } + TrainHandler tail = cars(loco).get(2); + tail.setPendingParent(cars(loco).get(1).v.getUUID()); + org.json.simple.JSONObject saved = new org.json.simple.JSONObject(); + tail.toConsistData().put(saved); + ConsistData restored = ConsistData.fromJson(saved); + assertEquals(true, restored.getJunctions().get(junction.id.toString())); + // A car well beyond the frog may load before its parent which still spans it. + TrainHandler loaded = car(); + loaded.applyConsist(new ConsistData(restored.getParent(), null, junction.branchSplineId.toString(), + 30d, 1, null, null, -1, restored.getJunctions())); + loaded.splineTick(); + assertTrue(loaded.holdsJunction(junction.id)); + loaded.setPendingParent(null); + loaded.placeLoadedCars(); + assertFalse(loaded.holdsJunction(junction.id)); + } + + @Test + void relinkRecoversCarriageSnapshotBeforePlacingTheTrain() { + TrackSpline stem = straightTrack(false); + TrackJunction junction = smoothTurnout(stem, 50, -1, false); + TrainHandler loco = car(); + loco.applyConsist(new ConsistData(null, null, stem.getId().toString(), 58d, -1)); + TrainHandler child = car(); + child.applyConsist(new ConsistData(loco.v.getUUID(), null, junction.branchSplineId.toString(), + 2d, 1, null, null, -1, java.util.Map.of(junction.id.toString(), true))); + child.splineTick(); + loco.setChild(child.v); + child.setPendingParent(null); + loco.placeLoadedCars(); + assertTrue(loco.holdsJunction(junction.id)); + assertEquals(junction.branchSplineId, child.getSplineId()); + assertEquals(2, child.getS(), 1e-6); + } + + private TrackJunction smoothTurnout(TrackSpline stem, double at, int facing, boolean thrown) { + List points = new ArrayList<>(); + for (int i = 0; i <= 90; i++) { + double a = Math.toRadians(i); + points.add(new double[]{facing * 32 * (1 - Math.cos(a)), 64, at + facing * 32 * Math.sin(a)}); + } + points.add(new double[]{facing * 132, 64, at + facing * 32}); + TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, points); + store.save(branch); + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), at, facing, + TrackJunction.Side.LEFT, branch.getId(), thrown, 12); + store.saveJunction("world", junction); + registry.loadFromDisk(); + return junction; + } + + private List cars(TrainHandler loco) { + List cars = new ArrayList<>(); + for (TrainHandler car = loco; car != null; car = car.hasChild() ? car.getChild().getTrainHandler() : null) { + cars.add(car); + } + return cars; + } + + private List carLocations(TrainHandler loco) { + return cars(loco).stream().map(car -> car.v.getEntity().getLocation()).toList(); + } + + private List captureRotations(TrainHandler loco) { + List rotations = new ArrayList<>(); + for (TrainHandler car : cars(loco)) { + float[] rotation = new float[2]; + rotations.add(rotation); + BoneRotator rotator = stub(BoneRotator.class); + doAnswer(call -> { + rotation[0] = call.getArgument(0); + rotation[1] = call.getArgument(1); + return true; + }).when(rotator).rotateToTarget(org.mockito.ArgumentMatchers.anyFloat(), + org.mockito.ArgumentMatchers.anyFloat(), org.mockito.ArgumentMatchers.anyFloat(), + org.mockito.ArgumentMatchers.anyFloat(), org.mockito.ArgumentMatchers.anyBoolean(), + org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyBoolean()); + BehaviourHandler behaviour = stub(BehaviourHandler.class); + when(behaviour.getRotator()).thenReturn(rotator); + when(car.v.getBehaviourHandler()).thenReturn(behaviour); + } + return rotations; + } + + private void assertContinuousCoupled(TrainHandler loco, List rotations, List before, double bound) { + List cars = cars(loco); + for (int i = 0; i < cars.size(); i++) { + assertTrue(before.get(i).toVector().distance(cars.get(i).v.getEntity().getLocation().toVector()) < bound, "Car jumped at a turnout"); + if (i > 0) { + assertEquals(0, modelAnchor(cars.get(i - 1), rotations.get(i - 1), -5) + .distance(modelAnchor(cars.get(i), rotations.get(i), 5)), 1e-5, + "car=" + i + " s=" + cars.get(i).getS() + " orientation=" + cars.get(i).getOrientation() + + " parent=" + cars.get(i - 1).getS() + " yaw=" + rotations.get(i)[0] + + " parentYaw=" + rotations.get(i - 1)[0] + " speed=" + loco.v.getAccessPanel().getSpeed()); + } + } + } + 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++) {