using System; using System.Collections.Generic; using HarmonyLib; using Model; using Model.Definition; using Model.Ops; using S3.Modules.Popout; using Track; using UnityEngine; namespace S3.Modules.CarCards; enum WaypointAction { Cut, Drop, Pickup, Couple, } sealed class WaypointDivider { public int Number; public Color Color; public WaypointAction Action; public string LeftId = ""; public string RightId = ""; /// /// When true, LeftId is the end car and RightId is the next car inward. /// The divider sits on the outer face of LeftId, away from RightId. /// public bool Outer; public string WaypointId = ""; public bool HasPosition; public Vector3 Position; public Quaternion Rotation = Quaternion.identity; public string Headline = ""; public string Detail = ""; } static class WaypointCutSim { const float NearCoupleM = 250f; public static void Fill(IReadOnlyList cards, List into, out string signature) { into.Clear(); signature = ""; if (cards == null || cards.Count == 0) return; if (!CarCardsModule.Settings.showWaypointCuts) return; if (!WaypointQueueBridge.IsInstalled) return; BaseLocomotive? loco = LocoWithQueue(cards); if (loco == null) return; if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0) return; Color color = MapWaypointSystem.ColorForLoco(loco.id); var present = new HashSet(StringComparer.Ordinal); string seedId = ""; for (int i = 0; i < cards.Count; i++) { string id = cards[i].Id; if (string.IsNullOrEmpty(id)) continue; present.Add(id); if (seedId.Length == 0) seedId = id; } if (present.Count == 0) return; // Couple-walk order (A-end to B-end), not the camera fan. View reverse // only affects where dividers are drawn, not which cars they sit between. var remaining = IdsOf(seedId); remaining.RemoveAll(id => !present.Contains(id)); if (remaining.Count == 0) { remaining = new List(present.Count); for (int i = 0; i < cards.Count; i++) { string id = cards[i].Id; if (!string.IsNullOrEmpty(id)) remaining.Add(id); } } var parked = new List(); var sig = new System.Text.StringBuilder(loco.id); sig.Append(':').Append(snaps.Count); if (remaining.Count > 0) sig.Append(':').Append(remaining[0]).Append('-').Append(remaining[remaining.Count - 1]); for (int w = 0; w < snaps.Count; w++) { var wp = snaps[w]; sig.Append('|').Append(wp.Number) .Append(':').Append(wp.CouplingSearchMode) .Append(':').Append(wp.UncouplingMode) .Append(':').Append(wp.NumberOfCarsToCut) .Append(':').Append(wp.CoupleToCarId); if (!Apply(wp, remaining, parked, present, into, color)) break; } signature = sig.ToString(); } static bool Apply( WaypointQueueBridge.WqWaypointSnap wp, List remaining, List parked, HashSet present, List into, Color color) { if (remaining.Count == 0) return false; bool coupling = WaypointQueueBridge.Coupling(wp); string coupleId = CoupleId(wp); if (coupling && string.IsNullOrEmpty(coupleId) && string.Equals(wp.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase)) { if (!TryResolveNearest(wp, remaining, parked, out coupleId)) return false; } if (coupling && string.IsNullOrEmpty(coupleId) && string.Equals(wp.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase)) coupleId = wp.CouplingSearchResultCarId ?? ""; if (Is(wp.UncouplingMode, "ByDestinationArea") || Is(wp.UncouplingMode, "ByDestinationIndustry") || Is(wp.UncouplingMode, "ByDestinationTrack")) return true; if (coupling && wp.Pickup && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0) { if (string.IsNullOrEmpty(coupleId)) return false; bool onTrain = remaining.Contains(coupleId); if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart)) return false; if (!onTrain) AddEndDivider(into, wp, color, WaypointAction.Pickup, remaining, atStart); if (!TryMergeForeign(remaining, coupleId, atStart, wp.NumberOfCarsToCut, parked, wp)) return false; return true; } if (coupling && wp.Dropoff && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0) { if (string.IsNullOrEmpty(coupleId)) return false; if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart)) return false; if (!TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart: atStart, parked, wp, into, color, WaypointAction.Drop, present)) return remaining.Count > 0; return true; } if (coupling && Is(wp.UncouplingMode, "BySpecificCar")) return true; if (coupling) { if (string.IsNullOrEmpty(coupleId)) return false; bool onTrain = remaining.Contains(coupleId); if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart)) return false; if (!onTrain) AddEndDivider(into, wp, color, WaypointAction.Couple, remaining, atStart); if (!TryMergeForeign(remaining, coupleId, atStart, keep: -1, parked, wp)) return false; return true; } if (Is(wp.UncouplingMode, "AllExceptLocomotives")) { CutAllExceptLocos(remaining, parked, wp, into, color, present); return remaining.Count > 0; } if (Is(wp.UncouplingMode, "ByCount")) { bool fromStart = !TakeFromLast(remaining, wp); TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart, parked, wp, into, color, WaypointAction.Cut, present); return remaining.Count > 0; } if (Is(wp.UncouplingMode, "BySpecificCar")) { string spec = wp.UncouplingSearchResultCarId ?? ""; if (string.IsNullOrEmpty(spec)) return true; int idx = remaining.IndexOf(spec); if (idx < 0) return true; bool fromStart = !TakeFromLast(remaining, wp); int n = fromStart ? idx + 1 : remaining.Count - idx; TryCutCount(remaining, n, fromStart, parked, wp, into, color, WaypointAction.Cut, present); return remaining.Count > 0; } return true; } static string CoupleId(WaypointQueueBridge.WqWaypointSnap wp) { if (!string.IsNullOrEmpty(wp.CoupleToCarId)) return wp.CoupleToCarId; if (!string.IsNullOrEmpty(wp.CouplingSearchResultCarId)) return wp.CouplingSearchResultCarId; return ""; } static bool TryCutCount( List remaining, int n, bool fromStart, List parked, WaypointQueueBridge.WqWaypointSnap wp, List into, Color color, WaypointAction action, HashSet present) { if (n <= 0 || n >= remaining.Count) return false; string left; string right; List cut; if (fromStart) { left = remaining[n - 1]; right = remaining[n]; cut = remaining.GetRange(0, n); remaining.RemoveRange(0, n); } else { int keep = remaining.Count - n; left = remaining[keep - 1]; right = remaining[keep]; cut = remaining.GetRange(keep, n); remaining.RemoveRange(keep, n); } Park(parked, cut, wp); if (present.Contains(left) || present.Contains(right)) { into.Add(DividerFrom(wp, color, action, left, right)); } return true; } static void CutAllExceptLocos( List remaining, List parked, WaypointQueueBridge.WqWaypointSnap wp, List into, Color color, HashSet present) { var keep = new List(); var cut = new List(); for (int i = 0; i < remaining.Count; i++) { if (IsLocoType(remaining[i])) keep.Add(remaining[i]); else cut.Add(remaining[i]); } if (cut.Count == 0) return; for (int i = 0; i < remaining.Count - 1; i++) { bool a = IsLocoType(remaining[i]); bool b = IsLocoType(remaining[i + 1]); if (a == b) continue; string left = remaining[i]; string right = remaining[i + 1]; if (!present.Contains(left) && !present.Contains(right)) continue; into.Add(DividerFrom(wp, color, WaypointAction.Cut, left, right)); } Park(parked, cut, wp); remaining.Clear(); remaining.AddRange(keep); } static void AddEndDivider( List into, WaypointQueueBridge.WqWaypointSnap wp, Color color, WaypointAction action, List remaining, bool atStart) { if (remaining.Count == 0) return; string end; string inward; if (atStart) { end = remaining[0]; inward = remaining.Count > 1 ? remaining[1] : ""; } else { end = remaining[remaining.Count - 1]; inward = remaining.Count > 1 ? remaining[remaining.Count - 2] : ""; } var d = DividerFrom(wp, color, action, end, inward); d.Outer = true; into.Add(d); } static WaypointDivider DividerFrom( WaypointQueueBridge.WqWaypointSnap wp, Color color, WaypointAction action, string leftId, string rightId) { return new WaypointDivider { Number = wp.Number, Color = color, Action = action, LeftId = leftId, RightId = rightId, WaypointId = wp.Id ?? "", HasPosition = wp.HasPosition, Position = wp.Position, Rotation = wp.Rotation, Headline = Headline(action, wp), Detail = DetailLines(wp, action), }; } static string Headline(WaypointAction action, WaypointQueueBridge.WqWaypointSnap wp) { int n = wp.NumberOfCarsToCut; return action switch { WaypointAction.Pickup => n > 0 ? $"Pickup {n}" : "Pickup", WaypointAction.Drop => n > 0 ? $"Drop {n}" : "Drop", WaypointAction.Couple => "Couple", _ => n > 0 ? $"Cut {n}" : "Cut", }; } static string DetailLines(WaypointQueueBridge.WqWaypointSnap wp, WaypointAction action) { var lines = new List(); if (!string.IsNullOrEmpty(wp.Name)) lines.Add(wp.Name); string place = PlaceName(wp); if (!string.IsNullOrEmpty(place)) lines.Add(place); string couple = CarMark(wp.CoupleToCarId); if (string.IsNullOrEmpty(couple)) couple = CarMark(wp.CouplingSearchResultCarId); if (!string.IsNullOrEmpty(couple) && action != WaypointAction.Cut) lines.Add("To " + couple); if (action == WaypointAction.Cut && wp.NumberOfCarsToCut > 0) lines.Add(wp.CountFromNearest ? "Nearest end" : "Furthest end"); if (wp.WillWait) lines.Add(wp.WaitMinutes > 0 ? $"Wait {wp.WaitMinutes} min" : "Wait"); if (wp.WillRefuel) lines.Add(string.IsNullOrEmpty(wp.RefuelLoad) ? "Refuel" : "Refuel " + wp.RefuelLoad); if (!string.IsNullOrEmpty(wp.Notes)) lines.Add(wp.Notes); return string.Join("\n", lines); } static string PlaceName(WaypointQueueBridge.WqWaypointSnap wp) { if (!string.IsNullOrEmpty(wp.AreaName)) return wp.AreaName; if (!wp.HasPosition) return ""; try { var ops = OpsController.Shared; if (ops == null) return ""; Area? area = ops.ClosestAreaForGamePosition(wp.Position); return area != null ? area.name : ""; } catch { return ""; } } static string CarMark(string id) { if (string.IsNullOrEmpty(id)) return ""; Car? c = ConsistBinder.Find(id); if (c == null) return ""; try { return string.IsNullOrEmpty(c.DisplayName) ? id : c.DisplayName; } catch { return id; } } static bool TryCoupleEnd( List remaining, WaypointQueueBridge.WqWaypointSnap wp, string coupleId, out string endId, out bool atStart) { endId = ""; atStart = false; if (remaining.Count == 0) return false; int already = remaining.IndexOf(coupleId); if (already >= 0) { atStart = already * 2 < remaining.Count; endId = coupleId; return true; } Vector3 hint = wp.HasPosition ? wp.Position : default; if (TryGamePosId(coupleId, out Vector3 couplePos)) hint = couplePos; else if (!wp.HasPosition) return false; atStart = !NearIsLast(remaining, hint); endId = atStart ? remaining[0] : remaining[remaining.Count - 1]; return true; } static bool TryMergeForeign( List remaining, string coupleId, bool atStart, int keep, List parked, WaypointQueueBridge.WqWaypointSnap wp) { if (remaining.Contains(coupleId)) return true; var foreign = IdsOf(coupleId); if (foreign.Count == 0) return false; foreign.RemoveAll(remaining.Contains); int ci = foreign.IndexOf(coupleId); if (ci < 0) { foreign.Insert(0, coupleId); ci = 0; } if (ci != 0 && ci != foreign.Count - 1) return false; if (ci == 0 && atStart) foreign.Reverse(); if (ci != 0 && !atStart) foreign.Reverse(); if (keep >= 0 && keep < foreign.Count) { List extra; List take; if (atStart) { int drop = foreign.Count - keep; extra = foreign.GetRange(0, drop); take = foreign.GetRange(drop, keep); } else { take = foreign.GetRange(0, keep); extra = foreign.GetRange(keep, foreign.Count - keep); } if (extra.Count > 0) Park(parked, extra, wp); foreign = take; } if (atStart) remaining.InsertRange(0, foreign); else remaining.AddRange(foreign); ForgetParked(parked, foreign); return true; } static bool TryResolveNearest( WaypointQueueBridge.WqWaypointSnap wp, List remaining, List parked, out string coupleId) { coupleId = ""; if (!wp.HasPosition) return false; Vector3 wpPos = wp.Position; var remainingSet = new HashSet(remaining); string bestParked = ""; float bestParkedD = NearCoupleM; for (int i = 0; i < parked.Count; i++) { var group = parked[i]; if (!TryLiveGroup(group.Ids, remainingSet, out List live, out float dist, wpPos)) continue; if (dist >= bestParkedD) continue; bestParkedD = dist; bestParked = ClosestId(live, wpPos); } if (!string.IsNullOrEmpty(bestParked)) { coupleId = bestParked; return true; } if (TryPollNearby(wpPos, remainingSet, out string pollId)) { coupleId = pollId; return true; } return false; } static bool TryLiveGroup( List ids, HashSet remaining, out List live, out float dist, Vector3 wpPos) { live = new List(); dist = float.MaxValue; string seed = ""; for (int i = 0; i < ids.Count; i++) { if (remaining.Contains(ids[i])) continue; if (ConsistBinder.Find(ids[i]) == null) continue; seed = ids[i]; break; } if (string.IsNullOrEmpty(seed)) return false; live = IdsOf(seed); if (live.Count == 0) return false; bool overlap = false; for (int i = 0; i < ids.Count; i++) { if (live.Contains(ids[i])) { overlap = true; break; } } if (!overlap) return false; string closest = ClosestId(live, wpPos); if (!TryGamePosId(closest, out Vector3 p)) return false; dist = Vector3.Distance(p, wpPos); return dist < NearCoupleM; } static bool TryPollNearby(Vector3 wpPos, HashSet remaining, out string id) { id = ""; TrainController? tc = null; try { tc = TrainController.Shared; } catch { } if (tc?.Cars == null) return false; float best = NearCoupleM; foreach (Car c in tc.Cars) { if (c == null || remaining.Contains(c.id)) continue; bool a = false, b = false; try { a = c[Car.LogicalEnd.A].IsCoupled; } catch { } try { b = c[Car.LogicalEnd.B].IsCoupled; } catch { } if (a && b) continue; if (!TryGamePos(c, out Vector3 p)) continue; float d = Vector3.Distance(p, wpPos); if (d >= best) continue; best = d; id = c.id; } return !string.IsNullOrEmpty(id); } static void Park(List parked, List ids, WaypointQueueBridge.WqWaypointSnap wp) { if (ids == null || ids.Count == 0) return; var copy = new List(ids); Vector3 pos = wp.HasPosition ? wp.Position : default; if (!wp.HasPosition) { for (int i = 0; i < copy.Count; i++) { if (TryGamePosId(copy[i], out pos)) break; } } parked.Add(new ParkedCut { Ids = copy, Pos = pos }); } static void ForgetParked(List parked, List taken) { if (taken.Count == 0) return; var set = new HashSet(taken); for (int i = parked.Count - 1; i >= 0; i--) { parked[i].Ids.RemoveAll(set.Contains); if (parked[i].Ids.Count == 0) parked.RemoveAt(i); } } static bool TakeFromLast(List remaining, WaypointQueueBridge.WqWaypointSnap wp) { bool nearLast = NearIsLast(remaining, wp.HasPosition ? wp.Position : default); return wp.CountFromNearest ? nearLast : !nearLast; } static bool NearIsLast(List remaining, Vector3 pos) { if (remaining.Count < 2) return true; if (!TryGamePosId(remaining[0], out Vector3 a)) return true; if (!TryGamePosId(remaining[remaining.Count - 1], out Vector3 b)) return true; return Vector3.Distance(b, pos) <= Vector3.Distance(a, pos); } static List IdsOf(string seedId) { var ids = new List(); Car? seed = ConsistBinder.Find(seedId); if (seed == null) return ids; try { foreach (Car c in seed.EnumerateCoupled()) if (c != null) ids.Add(c.id); } catch { } if (ids.Count == 0) ids.Add(seedId); return ids; } static string ClosestId(List ids, Vector3 pos) { string best = ids[0]; float bestD = float.MaxValue; for (int i = 0; i < ids.Count; i++) { if (!TryGamePosId(ids[i], out Vector3 p)) continue; float d = Vector3.Distance(p, pos); if (d >= bestD) continue; bestD = d; best = ids[i]; } return best; } static bool TryGamePosId(string id, out Vector3 pos) { pos = default; Car? c = ConsistBinder.Find(id); return c != null && TryGamePos(c, out pos); } static bool TryGamePos(Car car, out Vector3 pos) { pos = default; try { if (car == null || Graph.Shared == null) return false; pos = Graph.Shared.GetPosition(car.WheelBoundsA); return true; } catch { return false; } } static bool IsLocoType(string id) { Car? c = ConsistBinder.Find(id); if (c == null) return false; try { if (c.IsLocomotive) return true; var a = c.Archetype; return a == CarArchetype.LocomotiveDiesel || a == CarArchetype.LocomotiveSteam || a == CarArchetype.Tender; } catch { return c is BaseLocomotive; } } static BaseLocomotive? LocoWithQueue(IReadOnlyList cards) { BaseLocomotive? lead = null; BaseLocomotive? withQ = null; for (int i = 0; i < cards.Count; i++) { if (cards[i].Car is not BaseLocomotive loco) continue; lead ??= loco; if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0) continue; bool mu = false; try { mu = Traverse.Create(loco).Property("IsMuEnabled").Value; } catch { } if (!mu) return loco; withQ ??= loco; } return withQ ?? lead; } static bool Is(string value, string name) => string.Equals(value, name, StringComparison.OrdinalIgnoreCase); sealed class ParkedCut { public List Ids = new(); public Vector3 Pos; } }