using System; using System.Collections; using System.Collections.Generic; using System.Linq; using StarterNG.Classes; using StarterNG.Domain.Sceneries; using StarterNG.Domain.Vehicles; using StarterNG.Application; namespace StarterNG.Domain; public sealed class Consist : IReadOnlyList { private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; private readonly VehicleCatalog _db; private readonly VehicleInfo _info; private readonly List _items = new(); private int _nameCounter; public Consist(VehicleCatalog db, VehicleInfo info) { _db = db; _info = info; } public event Action? Changed; public Trainset? EditingTrainset { get; set; } public ConsistItem? Selected { get; set; } public Dynamic? SelectedCar { get; set; } public int Count => _items.Count; /// Every vehicle, a multi-unit counted car by car - counts units. public int VehicleCount => _items.Sum(i => i.Cars.Count); public ConsistItem this[int index] { get => _items[index]; set => _items[index] = value; } public IEnumerator GetEnumerator() => _items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public int IndexOf(ConsistItem item) => _items.IndexOf(item); public void Raise() => Changed?.Invoke(); /// The unit to select, by position; the first one otherwise. public void LoadFrom(Trainset trainset, int select = 0) { EditingTrainset = trainset; GroupIntoUnits(trainset.Vehicles); Selected = _items.Count == 0 ? null : _items[Math.Clamp(select, 0, _items.Count - 1)]; SyncStartingVehicle(); Raise(); } private void GroupIntoUnits(IEnumerable vehicles) { _items.Clear(); var cars = vehicles.Select(v => { var c = v.Clone(); c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName; return c; }).ToList(); int i = 0; while (i < cars.Count) { if (HoldsNext(cars[i])) { var locked = new List { cars[i] }; int j = i; while (j < cars.Count - 1 && HoldsNext(cars[j])) locked.Add(cars[++j]); _items.Add(Unit(locked)); i = j + 1; continue; } VehicleSet? set = null; if (_db.TextureFor(cars[i].DataFolder, cars[i].SkinFile, cars[i].MmdFile)?.Uuid is { } uuid) _db.SetByTextureUuid.TryGetValue(uuid, out set); if (set?.TextureRefs is { Count: > 1 }) { var members = new HashSet(set.TextureRefs.Where(r => !string.IsNullOrEmpty(r))); var group = new List(); int j = i; while (j < cars.Count && group.Count < set.TextureRefs.Count) { if (_db.TextureFor(cars[j].DataFolder, cars[j].SkinFile, cars[j].MmdFile)?.Uuid is { } u && members.Contains(u)) group.Add(cars[j++]); else break; } if (group.Count >= 2) { _items.Add(Unit(group)); i = j; continue; } } if (IsUnitCar(cars[i])) { string key = UnitKey(cars[i]); var group = new List { cars[i] }; int j = i + 1; while (j < cars.Count && IsUnitCar(cars[j]) && UnitKey(cars[j]) == key) group.Add(cars[j++]); if (group.Count >= 2) { _items.Add(Unit(group)); i = j; continue; } } _items.Add(new ConsistItem { Cars = new List { cars[i] }, Grouped = false, Flipped = cars[i].Offset < 0, Driver = cars[i].DriverType }); i++; } } /// /// A unit from cars in the order they run. A turned unit keeps its own order /// reversed, which undoes - kept as it runs, its cars /// would swap ends with every load. /// private static ConsistItem Unit(List running) { bool flipped = running[0].Offset < 0; if (flipped) running.Reverse(); return new ConsistItem { Cars = running, Grouped = true, Flipped = flipped, Driver = UnitDriver(running) }; } /// Nothing to edit: the strip goes empty until a consist is loaded again. public void Unload() { EditingTrainset = null; _items.Clear(); Selected = null; SelectedCar = null; Raise(); } public List Flatten() => _items.SelectMany(RunningOrder).ToList(); /// A card's cars in the order they run, which is the order the export writes. public static IEnumerable RunningOrder(ConsistItem item) => item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars; /// /// The vehicle the simulator puts the player into follows the selection - but /// only onto a unit someone sits in. Selecting a wagon to edit it, or adding /// one, which selects it, keeps the pick made before; otherwise Start would /// read "no crew" and the simulator would open in an empty wagon. /// public void SyncStartingVehicle() { // A consist from the warehouse, say, is edited here but not the one started. var state = AppServices.Current.State; if (EditingTrainset is null || !ReferenceEquals(EditingTrainset, state.CurrentTrainset)) return; if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick) state.StartingVehicleName = pick; } /// /// The selected unit's driver, or its passenger; with no one in it, the pick /// made before while it still can be started from, else the first crew there is. /// public static string? StartingVehicle(IEnumerable items, ConsistItem? selected, string? current) { var cars = items.SelectMany(i => i.Cars).ToList(); string? pick = selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name ?? selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Passenger)?.Name; if (pick != null) return pick; if (cars.Any(c => IsNamed(c, current) && Crewed(c))) return current; return cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name ?? cars.FirstOrDefault(Crewed)?.Name; } private static bool Crewed(Dynamic car) => car.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; private static bool IsNamed(Dynamic car, string? name) => !string.IsNullOrEmpty(name) && string.Equals(car.Name, name, StringComparison.OrdinalIgnoreCase); public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery) { string skin = Base(texture.Skinfile); return new Dynamic { RangeMax = -1, RangeMin = 0, Name = MakeUniqueName(skin, scenery), DataFolder = StripDynamicPrefix(texture.Directory), SkinFile = skin, MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!, Offset = 0f, DriverType = eDriverType.Nobody, Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, HasVelocity = true, MiniName = _db.ResolveMiniName(texture) }; } private string MakeUniqueName(string baseName, Scenery? scenery) { if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle"; baseName = baseName.Replace(' ', '_'); var used = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var item in _items) foreach (var c in item.Cars) if (!string.IsNullOrEmpty(c.Name)) used.Add(c.Name!); if (scenery != null) { foreach (string n in scenery.LooseVehicleNames) used.Add(n); foreach (var ts in scenery.Trainsets) { if (ReferenceEquals(ts, EditingTrainset)) continue; foreach (var v in ts.Vehicles) if (!string.IsNullOrEmpty(v.Name)) used.Add(v.Name!); } } string candidate; do candidate = $"{baseName}_{++_nameCounter}"; while (used.Contains(candidate)); return candidate; } private static string StripDynamicPrefix(string directory) { string d = directory.Replace('\\', '/').TrimEnd('/'); const string prefix = "dynamic/"; if (d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) d = d[prefix.Length..]; return d; } public void Insert(int at, ConsistItem item) => _items.Insert(Math.Clamp(at, 0, _items.Count), item); /// /// Crew for a vehicle joining at position: a driver in cab A when it is powered /// and either leads or the consist has no driver yet, nobody otherwise. /// public void Staff(ConsistItem item, int position) { item.Driver = eDriverType.Nobody; foreach (var car in item.Cars) car.DriverType = eDriverType.Nobody; if (!IsPowered(item)) return; bool staffed = _items.Any(other => !ReferenceEquals(other, item) && other.Driver is eDriverType.Headdriver or eDriverType.Reardriver); if (position == 0 || !staffed) Crew(item, eDriverType.Headdriver); } /// /// Puts item in place of the vehicle at index, facing the same way. Its crew /// moves over where the replacement can take it - a driver only into something /// powered - and the rest is staffed as a newly added vehicle would be. /// public void Replace(int index, ConsistItem item) { var replaced = _items[index]; _items[index] = item; Staff(item, index); // Facing the way the replaced vehicle did - cars and all, as Flip would. if (item.Flipped != replaced.Flipped) Turn(index, index); var crew = replaced.Driver; if (crew == eDriverType.Passenger || (crew is eDriverType.Headdriver or eDriverType.Reardriver && IsPowered(item))) Crew(item, crew); } // The crew lives on the cars - they are what is shown and exported - and the // card only mirrors it. A unit has one crew, in the car that has the cab. private static void Crew(ConsistItem item, eDriverType crew) { if (item.Cars.Count == 0) return; foreach (var car in item.Cars) car.DriverType = eDriverType.Nobody; CrewCar(item, crew).DriverType = crew; item.Driver = crew; } /// /// Where a unit's crew sits: cab 1 is at the front of its first car and cab 2 /// at the back of its last - the ET42-A has only cab 1, the ET42-B only cab 2. /// The unit's own order, so turning it round takes the cabs along. /// public static Dynamic CrewCar(ConsistItem item, eDriverType crew) => crew == eDriverType.Reardriver ? item.Cars[^1] : item.Cars[0]; /// Puts the crew into the unit, in the car its cab is in. public void SetCrew(ConsistItem item, eDriverType crew) { Crew(item, crew); if (ReferenceEquals(Selected, item)) SyncStartingVehicle(); Raise(); AppServices.Current.State.NotifyChanged(); } private bool IsPowered(ConsistItem item) => item.Cars.Count > 0 && _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is { } texture && VehicleInfo.IsPoweredCategory(VehicleInfo.CategoryOf(texture)); public void MoveLeft(ConsistItem item) { int i = _items.IndexOf(item); if (i > 0) Move(i, i - 1); } public void MoveRight(ConsistItem item) { int i = _items.IndexOf(item); if (i >= 0 && i < _items.Count - 1) Move(i, i + 1); } public void Move(int from, int to) { if (from < 0 || from >= _items.Count) return; var previousHead = _items[0]; var item = _items[from]; _items.RemoveAt(from); to = Math.Clamp(to, 0, _items.Count); _items.Insert(to, item); HandOverLead(previousHead); Selected = item; AutoConnectAll(); Raise(); } /// /// A locomotive that comes to the head of the train takes over the driver of /// the one it put behind, in the same cab, and that one is left unstaffed. /// private void HandOverLead(ConsistItem previousHead) { var head = _items[0]; if (ReferenceEquals(head, previousHead) || !IsPowered(head) || previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver)) return; var crew = previousHead.Driver; previousHead.Driver = eDriverType.Nobody; foreach (var car in previousHead.Cars) car.DriverType = eDriverType.Nobody; Crew(head, crew); } /// /// Vehicles loaded in place of a consist keep its kind of crew: in the unit the /// loaded ones have crew in, else in the first powered one. A driver goes only /// into something powered, and with nobody in the old consist nobody is in the new. /// public static List LoadedOver(IReadOnlyList before, List loaded, VehicleCatalog db, VehicleInfo info) { if (before.Count == 0 || loaded.Count == 0) return loaded; var consist = new Consist(db, info); consist.GroupIntoUnits(loaded); var crew = UnitDriver(before); if (crew == eDriverType.Nobody) { foreach (var item in consist._items) Crew(item, eDriverType.Nobody); return consist.Flatten(); } var seat = consist._items.FirstOrDefault(item => item.Driver != eDriverType.Nobody) ?? consist._items.FirstOrDefault(consist.IsPowered); if (seat is not null && (crew == eDriverType.Passenger || consist.IsPowered(seat))) Crew(seat, crew); return consist.Flatten(); } /// Vehicles joining a consist that has its crew come without their own. public static List Joined(IEnumerable before, IEnumerable added) { var joined = before.ToList(); bool crewed = joined.Count > 0; foreach (var car in added) { if (crewed) car.DriverType = eDriverType.Nobody; joined.Add(car); } return joined; } public void Remove(ConsistItem item) { int gap = _items.IndexOf(item); _items.Remove(item); // The car now at the end still holds the coupler of the one taken off it. if (gap == _items.Count) FreeTail(); if (ReferenceEquals(Selected, item)) Selected = _items.Count == 0 ? null : _items[Math.Clamp(gap, 0, _items.Count - 1)]; Raise(); } public void Flip(ConsistItem item) { int index = _items.IndexOf(item); if (index < 0) return; var (first, last) = UnitAround(index); Turn(first, last); AutoConnectAll(); Raise(); } /// The cards locked together with the one at index, as the range they span. private (int First, int Last) UnitAround(int index) { int first = index; while (first > 0 && HoldsTail(_items[first - 1])) first--; int last = index; while (last + 1 < _items.Count && HoldsTail(_items[last])) last++; return (first, last); } /// /// Turns the cards first..last end for end - the flag on each card, the cars it /// holds and the order of the cards. All three or nothing: the export writes the /// cars in card order and each car with its own orientation, so a flag turned /// alone couples a multi-car unit back to front. /// private void Turn(int first, int last) { for (int k = first; k <= last; k++) { var card = _items[k]; card.Flipped = !card.Flipped; foreach (var car in card.Cars) car.Offset = car.Offset >= 0 ? -1f : 0f; } if (last > first) _items.Reverse(first, last - first + 1); } public void CycleDriver(ConsistItem item) => SetCrew(item, item.Driver switch { eDriverType.Nobody => eDriverType.Headdriver, eDriverType.Headdriver => eDriverType.Reardriver, eDriverType.Reardriver => eDriverType.Passenger, _ => eDriverType.Nobody }); public void Split(ConsistItem item) { int i = _items.IndexOf(item); if (i < 0) return; var order = RunningOrder(item).ToList(); _items.RemoveAt(i); for (int c = 0; c < order.Count; c++) { _items.Insert(i + c, new ConsistItem { Cars = new List { order[c] }, Grouped = false, // Each car's own way - a unit joined from both ways splits back into them. Flipped = IsTurned(order[c]), Driver = order[c].DriverType }); } Selected = _items[i]; Raise(); } public void Join(ConsistItem item) { int index = _items.IndexOf(item); if (index < 0) return; int first = index; while (first > 0 && HoldsTail(_items[first - 1])) first--; int last = index; bool inUnit = first < index || HoldsTail(_items[index]); if (!inUnit && last + 1 < _items.Count) { var card = _items[last]; if (card.Cars.Count > 0) TailCar(card).Coupling.Set(Coupling.WorkshopLock, true); last++; } while (last + 1 < _items.Count && HoldsTail(_items[last])) last++; if (last == first) return; // The cars as they run, each card read the way it faces, so joining vehicles // that face different ways leaves every car where and how it was. The unit // keeps the first card's flag; its cars are stored against that flag. var cars = new List(); var driver = eDriverType.Nobody; for (int k = first; k <= last; k++) { var card = _items[k]; cars.AddRange(RunningOrder(card)); if (driver == eDriverType.Nobody) driver = card.Driver; } bool flipped = _items[first].Flipped; if (flipped) cars.Reverse(); var joined = new ConsistItem { Cars = cars, Grouped = true, Flipped = flipped, Driver = driver }; _items.RemoveRange(first, last - first + 1); _items.Insert(first, joined); Selected = joined; AutoConnectAll(); Raise(); } public void RandomizeOrder(Random rng) { int start = Selected != null ? _items.IndexOf(Selected) : 0; if (start < 0) start = 0; var indices = new List(); for (int i = start; i < _items.Count; i++) { if (_items[i].Grouped) continue; string? cat = _info.CategoryOf(_items[i].Cars[0]); if (cat is { Length: 1 } && char.IsUpper(cat[0])) indices.Add(i); } if (indices.Count < 2) return; var items = indices.Select(i => _items[i]).ToList(); for (int i = items.Count - 1; i > 0; i--) { int j = rng.Next(i + 1); (items[i], items[j]) = (items[j], items[i]); } for (int k = 0; k < indices.Count; k++) _items[indices[k]] = items[k]; AutoConnectAll(); Raise(); } public void RandomizeOrientation(Random rng) { int start = Selected != null ? _items.IndexOf(Selected) : 0; if (start < 0) start = 0; if (start >= _items.Count) return; // Unit by unit, as Flip turns them: half a locked unit turned on its own // would be coupled back to front. for (int i = start; i < _items.Count;) { var (first, last) = UnitAround(i); if (rng.Next(2) == 0) Turn(first, last); i = last + 1; } AutoConnectAll(); Raise(); } /// /// The Auto button: couplers picked afresh for the whole consist, hand-set ones /// included, so the back of the train is 0 again. /// public void AutoCouple() { foreach (var car in Flatten()) car.CouplerSetByHand = false; AutoConnectAll(); } public void AutoConnectAll() { var flat = Flatten(); for (int i = 0; i < flat.Count - 1; i++) { var left = flat[i]; var right = flat[i + 1]; bool lf = IsTurned(left); bool rf = IsTurned(right); var lp = _info.PhysicsFor(left); var rp = _info.PhysicsFor(right); int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); int common = leftMax & rightMax; string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); if ((common & Coupling.ControlMU) != 0 && !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase)) common &= ~Coupling.ControlMU; left.Coupling.Flags = left.Coupling.Locked ? -common : common; left.CouplerSetByHand = false; } FreeTail(); } public bool CanFormUnit(ConsistItem left, ConsistItem right) { if (left.Cars.Count == 0 || right.Cars.Count == 0) return false; var tail = TailCar(left); var head = HeadCar(right); var lp = _info.PhysicsFor(tail); var rp = _info.PhysicsFor(head); int leftMax = lp == null ? 3 : (IsTurned(tail) ? lp.AllowedFlagA : lp.AllowedFlagB); int rightMax = rp == null ? 3 : (IsTurned(head) ? rp.AllowedFlagB : rp.AllowedFlagA); return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head); } public bool SameSet(Dynamic a, Dynamic b) { if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b)) return true; string? ua = _db.TextureFor(a.DataFolder, a.SkinFile, a.MmdFile)?.Uuid; string? ub = _db.TextureFor(b.DataFolder, b.SkinFile, b.MmdFile)?.Uuid; if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub)) return false; return _db.SetByTextureUuid.TryGetValue(ua!, out var sa) && _db.SetByTextureUuid.TryGetValue(ub!, out var sb) && ReferenceEquals(sa, sb) && sa.TextureRefs.Count > 1; } public Dynamic ActiveCar(ConsistItem item) => SelectedCar != null && item.Cars.Contains(SelectedCar) ? SelectedCar : item.Cars[0]; public static IEnumerable UnitTargets(ConsistItem item) => item.Cars; public IEnumerable MemberTargets(ConsistItem item) => item.Cars.Count == 1 ? item.Cars : new[] { ActiveCar(item) }; public static Dynamic TailCar(ConsistItem item) => item.Flipped ? item.Cars[0] : item.Cars[^1]; public static Dynamic HeadCar(ConsistItem item) => item.Flipped ? item.Cars[^1] : item.Cars[0]; /// /// Clears the coupler at the back of the consist, unless it was set in the /// editor. The simulator hangs the end signals only on a last vehicle whose /// coupler is 0 - the code does nothing else at the back of a train - and every /// new car comes with 3. Its parameters, the brake setting among them, stay. /// private void FreeTail() { if (_items.Count == 0 || _items[^1].Cars.Count == 0) return; var tail = TailCar(_items[^1]); if (!tail.CouplerSetByHand) tail.Coupling.Flags = 0; } /// /// Whether a car runs turned. Its own sign is the truth - it is what the export /// writes - and decides which coupler is at which end; a card's flag agrees /// with it except in a unit joined from vehicles facing different ways. /// public static bool IsTurned(Dynamic car) => car.Offset < 0; public static bool HoldsTail(ConsistItem item) => item.Cars.Count > 0 && HoldsNext(TailCar(item)); /// Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it. public bool CanJoin(ConsistItem item, int index) => (index + 1 < Count && (HoldsTail(item) || CanFormUnit(item, this[index + 1]))) || (index > 0 && HoldsTail(this[index - 1])); public static bool HoldsNext(Dynamic car) => car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked; public static eDriverType UnitDriver(IReadOnlyList group) => group.Select(c => c.DriverType) .Where(d => d != eDriverType.Nobody) .DefaultIfEmpty(eDriverType.Nobody) .First(); public static string Base(string skinOrName) => System.IO.Path.GetFileNameWithoutExtension(skinOrName); public static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile)); public static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile); private static string StripCarSuffix(string name) { foreach (string suf in CarSuffixes) { if (name.Length > suf.Length && name.EndsWith(suf, StringComparison.OrdinalIgnoreCase) && char.IsDigit(name[name.Length - suf.Length - 1])) return name[..^suf.Length]; } return name; } public static string UnitLabel(ConsistItem item) { string key = UnitKey(item.Cars[0]); return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key; } public static int GetWagonNumber(Dynamic car) { string name = car.Name ?? ""; int hash = name.LastIndexOf('#'); if (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit) && int.TryParse(name[(hash + 1)..], out int n)) return n; return 0; } public void SetWagonNumber(Dynamic car, int number) { string name = car.Name ?? ""; int hash = name.LastIndexOf('#'); string baseName = (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit)) ? name[..hash] : name; car.Name = number > 0 ? $"{baseName}#{number}" : baseName; Raise(); } }