Files
StarterNG/StarterNG/Domain/Consist.cs
maj00r ee87055eed Seat a unit's crew in the car that has the cab
A driver in cab 1 goes into the first car of a unit and one in cab 2 into the
last - the ET42-A has only cab 1, the ET42-B only cab 2. The crew is the
unit's: the card, the details and replacing a vehicle all set it there,
rather than in whichever car happened to be selected.
2026-10-04 19:11:44 +02:00

730 lines
24 KiB
C#

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<ConsistItem>
{
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
private readonly VehicleCatalog _db;
private readonly VehicleInfo _info;
private readonly List<ConsistItem> _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;
/// <summary>Every vehicle, a multi-unit counted car by car - <see cref="Count"/> counts units.</summary>
public int VehicleCount => _items.Sum(i => i.Cars.Count);
public ConsistItem this[int index]
{
get => _items[index];
set => _items[index] = value;
}
public IEnumerator<ConsistItem> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public int IndexOf(ConsistItem item) => _items.IndexOf(item);
public void Raise() => Changed?.Invoke();
public void LoadFrom(Trainset trainset)
{
EditingTrainset = trainset;
_items.Clear();
var cars = trainset.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<Dynamic> { cars[i] };
int j = i;
while (j < cars.Count - 1 && HoldsNext(cars[j]))
locked.Add(cars[++j]);
_items.Add(new ConsistItem
{
Cars = locked,
Grouped = true,
Flipped = locked[0].Offset < 0,
Driver = UnitDriver(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<string>(set.TextureRefs.Where(r => !string.IsNullOrEmpty(r)));
var group = new List<Dynamic>();
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(new ConsistItem
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
i = j;
continue;
}
}
if (IsUnitCar(cars[i]))
{
string key = UnitKey(cars[i]);
var group = new List<Dynamic> { 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(new ConsistItem
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
i = j;
continue;
}
}
_items.Add(new ConsistItem
{
Cars = new List<Dynamic> { cars[i] },
Grouped = false,
Flipped = cars[i].Offset < 0,
Driver = cars[i].DriverType
});
i++;
}
Selected = _items.FirstOrDefault();
SyncStartingVehicle();
Raise();
}
public List<Dynamic> Flatten() => _items.SelectMany(RunningOrder).ToList();
/// <summary>A card's cars in the order they run, which is the order the export writes.</summary>
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
public void SyncStartingVehicle()
{
if (Selected is not { Cars.Count: > 0 } item)
return;
var driven = item.Cars.FirstOrDefault(c =>
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
}
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<string>(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);
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static Dynamic CrewCar(ConsistItem item, eDriverType crew) =>
crew == eDriverType.Reardriver ? item.Cars[^1] : item.Cars[0];
/// <summary>Puts the crew into the unit, in the car its cab is in.</summary>
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();
}
/// <summary>
/// A locomotive that comes to the head of the train takes over the driver of
/// the one it put behind, which is left unstaffed: the train is driven from its
/// front, from cab A.
/// </summary>
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;
previousHead.Driver = eDriverType.Nobody;
foreach (var car in previousHead.Cars)
car.DriverType = eDriverType.Nobody;
Crew(head, eDriverType.Headdriver);
}
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();
}
/// <summary>The cards locked together with the one at index, as the range they span.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<Dynamic> { 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<Dynamic>();
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<int>();
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();
}
/// <summary>
/// The Auto button: couplers picked afresh for the whole consist, hand-set ones
/// included, so the back of the train is 0 again.
/// </summary>
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<Dynamic> UnitTargets(ConsistItem item) => item.Cars;
public IEnumerable<Dynamic> 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];
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static bool IsTurned(Dynamic car) => car.Offset < 0;
public static bool HoldsTail(ConsistItem item) =>
item.Cars.Count > 0 && HoldsNext(TailCar(item));
public static bool HoldsNext(Dynamic car) =>
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;
public static eDriverType UnitDriver(IReadOnlyList<Dynamic> 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();
}
}