Take the remaining I/O out of the domain and drop AppState.Instance

Scenery translations and load weights get repositories; AppState moves to the
application layer.
This commit is contained in:
maj00r
2026-09-04 21:06:23 +02:00
parent 0444070e12
commit 8e5fe01104
16 changed files with 479 additions and 216 deletions

View File

@@ -0,0 +1,41 @@
using StarterNG.Classes;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class LoadWeightsTests
{
private static LoadWeightsRepository Repository(InMemoryFileSystem files)
{
var installation = new TestInstallation(files);
return new LoadWeightsRepository(files, installation.Paths, installation.Log);
}
[Fact]
public void Weights_are_read_from_the_pairs_in_the_file()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("data", "load_weights.txt"),
"{\n coal : 850 # węgiel\n sand : 1600\n}");
var table = Repository(files).Table;
Assert.Equal(850, table.WeightOf("coal"));
Assert.Equal(1600, table.WeightOf("SAND"));
}
[Fact]
public void An_unknown_cargo_falls_back_to_the_default_weight()
{
Assert.Equal(LoadWeightsTable.DefaultWeight,
Repository(new InMemoryFileSystem()).Table.WeightOf("nieznany"));
}
[Fact]
public void The_pantograph_pseudo_load_weighs_nothing()
{
Assert.Equal(0, new LoadWeightsTable().WeightOf(Dynamic.PantState));
}
}

View File

@@ -0,0 +1,52 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryTranslationsTests
{
private static string I18n(string file) => Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")),
"i18n", file);
[Fact]
public void An_at_reference_is_resolved_against_the_sceneries_own_table()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @mission.brief")
.WithFile(I18n("kato_en.json"), """{ "mission": { "brief": "Take the stopper to Katowice" } }""");
var installation = new TestInstallation(files);
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var translations = new SceneryTranslations(files, installation.Log);
translations.LoadFor(scenery, "en");
Assert.Equal("Take the stopper to Katowice", scenery.LocalizedDescription(translations));
}
[Fact]
public void The_users_language_is_layered_over_english()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @brief")
.WithFile(I18n("kato_en.json"), """{ "brief": "English" }""")
.WithFile(I18n("kato_pl.json"), """{ "brief": "Polski" }""");
var installation = new TestInstallation(files);
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var translations = new SceneryTranslations(files, installation.Log);
translations.LoadFor(scenery, "Polski");
Assert.Equal("Polski", scenery.LocalizedDescription(translations));
}
[Fact]
public void Literal_text_and_unknown_keys_are_left_alone()
{
var installation = new TestInstallation();
var translations = new SceneryTranslations(installation.Files, installation.Log);
Assert.Equal("zwykły opis", translations.Translate("zwykły opis"));
Assert.Equal("@nieznany.klucz", translations.Translate("@nieznany.klucz"));
Assert.Equal("", translations.Translate(null));
}
}

View File

@@ -0,0 +1,23 @@
using StarterNG.Domain.Sceneries;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// The translations a scenery ships alongside itself, in scenery/i18n/*.json.
/// </summary>
/// <remarks>
/// Scenery text may be an "@key" reference rather than literal prose, resolved
/// against the currently loaded scenery's table. The table is per scenery, so
/// <see cref="LoadFor"/> replaces it rather than adding to it.
/// </remarks>
public interface ISceneryTranslations
{
/// <summary>Loads the tables for one scenery, English first then the user's language.</summary>
void LoadFor(Scenery scenery, string langCode);
/// <summary>
/// Resolves an "@key" reference against the loaded table. Text that is not a
/// reference, or a key with no translation, comes back unchanged.
/// </summary>
string Translate(string? text);
}

View File

@@ -44,7 +44,10 @@ public sealed class AppServices
Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser()); Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
Physics = new FizPhysicsRepository(files, paths, log); Physics = new FizPhysicsRepository(files, paths, log);
MissingAssets = new MissingAssetScanner(files, paths); MissingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
LoadWeights = new LoadWeightsRepository(files, paths, log);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log); Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState();
} }
/// <summary> /// <summary>
@@ -90,9 +93,16 @@ public sealed class AppServices
public MissingAssetScanner MissingAssets { get; } public MissingAssetScanner MissingAssets { get; }
public ISceneryTranslations SceneryTexts { get; }
public LoadWeightsRepository LoadWeights { get; }
/// <summary>Everything read out of the installation: rolling stock and scenarios.</summary> /// <summary>Everything read out of the installation: rolling stock and scenarios.</summary>
public GameLibrary Library { get; } public GameLibrary Library { get; }
/// <summary>What the user currently has selected.</summary>
public AppState State { get; }
/// <summary> /// <summary>
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/> /// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
/// because the XAML binds to it as a change-notifying source; code should take /// because the XAML binds to it as a change-notifying source; code should take

View File

@@ -1,12 +1,22 @@
using System; using System;
using StarterNG.Domain.Sceneries; using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes; using StarterNG.Classes;
namespace StarterNG.Application;
/// <summary>
/// What the user currently has selected: which scenario, which consist in it and
/// which vehicle they will start in.
/// </summary>
/// <remarks>
/// Session state rather than domain state, which is why it lives in the
/// application layer. Built by the composition root instead of being an ambient
/// singleton; screens observe <see cref="Changed"/> to stay in step.
/// </remarks>
public sealed class AppState public sealed class AppState
{ {
public static AppState Instance { get; } = new(); /// <summary>Raised whenever any of the selections changes.</summary>
public event Action? Changed; public event Action? Changed;
private Scenery? _currentScenery; private Scenery? _currentScenery;

View File

@@ -6,6 +6,7 @@ using System.Linq;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain.Sceneries; using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles; using StarterNG.Domain.Vehicles;
using StarterNG.Application;
namespace StarterNG.Domain; namespace StarterNG.Domain;
@@ -170,7 +171,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
var driven = item.Cars.FirstOrDefault(c => var driven = item.Cars.FirstOrDefault(c =>
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
AppState.Instance.StartingVehicleName = (driven ?? item.Cars[0]).Name; AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
} }
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery) public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
@@ -324,7 +325,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (ReferenceEquals(Selected, item)) if (ReferenceEquals(Selected, item))
SyncStartingVehicle(); SyncStartingVehicle();
Raise(); Raise();
AppState.Instance.NotifyChanged(); AppServices.Current.State.NotifyChanged();
} }
public void Split(ConsistItem item) public void Split(ConsistItem item)

View File

@@ -1,73 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using StarterNG.Classes;
namespace StarterNG.Domain;
public static class LoadWeights
{
private static Dictionary<string, int>? _weights;
public static int WeightOf(string? name)
{
Load();
return !string.IsNullOrEmpty(name) && _weights!.TryGetValue(name!, out int w) ? w : 1000;
}
public static string Describe(string name) =>
string.Equals(name, Dynamic.PantState, StringComparison.OrdinalIgnoreCase)
? App.Loc["LoadPantState"]
: name;
private static void Load()
{
if (_weights != null)
return;
var weights = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[Dynamic.PantState] = 0
};
try
{
string path = Path.Combine("data", "load_weights.txt");
if (File.Exists(path))
{
var body = new StringBuilder();
foreach (var raw in File.ReadAllLines(path, Encoding.GetEncoding(1250)))
{
string line = raw;
int comment = line.IndexOfAny(new[] { '#', ';' });
if (comment >= 0) line = line[..comment];
int slashes = line.IndexOf("//", StringComparison.Ordinal);
if (slashes >= 0) line = line[..slashes];
body.Append(line).Append(' ');
}
var tokens = body.Replace(":", " : ").ToString()
.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 1; i + 1 < tokens.Length; i++)
{
if (tokens[i] != ":") continue;
string name = tokens[i - 1];
if (name is "{" or "}") continue;
if (!int.TryParse(tokens[i + 1], NumberStyles.Integer,
CultureInfo.InvariantCulture, out int w))
continue;
weights[name] = w;
}
}
}
catch (Exception ex)
{
Infrastructure.Diagnostics.Log("data/load_weights.txt", ex);
}
_weights = weights;
}
}

View File

@@ -96,10 +96,14 @@ public sealed class Scenery
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
public string LocalizedDescription => /// <summary>
/// The description with any "@key" references resolved against the scenery's
/// own translation files.
/// </summary>
public string LocalizedDescription(ISceneryTranslations translations) =>
string.IsNullOrEmpty(Description) string.IsNullOrEmpty(Description)
? "" ? ""
: string.Join("\n", Description.Split('\n').Select(SceneryI18n.T)); : string.Join("\n", Description.Split('\n').Select(translations.Translate));
/// <summary> /// <summary>
/// Renders the scenario the simulator should load: the original file with the /// Renders the scenario the simulator should load: the original file with the

View File

@@ -1,89 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain;
public static class SceneryI18n
{
private static readonly Dictionary<string, string> Map =
new(StringComparer.OrdinalIgnoreCase);
public static void Clear() => Map.Clear();
public static void LoadFor(Scenery scenery, string langCode = "en")
{
Map.Clear();
string scnDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
string id = Path.GetFileNameWithoutExtension(scenery.Path);
string lang = NormalizeLang(langCode);
string i18nDir = Path.Combine(scnDir, "i18n");
TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
}
public static string T(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return text ?? "";
string t = text.TrimStart();
if (t.Length == 0 || t[0] != '@') return text;
string key = t[1..].Trim();
return Map.TryGetValue(key, out var v) ? v : text;
}
private static void TryLoad(string path)
{
if (!File.Exists(path)) return;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind == JsonValueKind.Object)
Flatten("", doc.RootElement);
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.Log($"scenery i18n/{Path.GetFileName(path)}", ex);
}
}
private static void Flatten(string prefix, JsonElement obj)
{
foreach (var prop in obj.EnumerateObject())
{
string key = string.IsNullOrEmpty(prefix) ? prop.Name : prefix + "." + prop.Name;
switch (prop.Value.ValueKind)
{
case JsonValueKind.String:
Map[key] = prop.Value.GetString() ?? "";
break;
case JsonValueKind.Number:
case JsonValueKind.True:
case JsonValueKind.False:
Map[key] = prop.Value.ToString();
break;
case JsonValueKind.Object:
Flatten(key, prop.Value);
break;
}
}
}
private static string NormalizeLang(string language)
{
string l = language.Trim().ToLowerInvariant();
return l switch
{
"polski" or "pl" => "pl",
"english" or "en" => "en",
"čeština" or "cesky" or "czech" or "cs" or "cz" => "cz",
"magyar" or "hungarian" or "hu" => "hu",
"русский" or "russian" or "ru" => "ru",
_ => l.Length >= 2 ? l[..2] : "en"
};
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using StarterNG.Classes;
namespace StarterNG.Domain.Vehicles;
/// <summary>
/// How heavy a tonne of each cargo type is, as declared by
/// data/load_weights.txt, used to turn a load into a mass.
/// </summary>
/// <remarks>
/// Was a static class that also read the file. The table is now a value the
/// repository builds; an unknown cargo weighs <see cref="DefaultWeight"/>, which
/// is what the simulator assumes.
/// </remarks>
public sealed class LoadWeightsTable
{
/// <summary>Weight assumed for a cargo the file does not mention.</summary>
public const int DefaultWeight = 1000;
private readonly Dictionary<string, int> _weights;
public LoadWeightsTable(IReadOnlyDictionary<string, int>? weights = null)
{
_weights = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
// The pantograph state is carried as a pseudo load and weighs nothing.
[Dynamic.PantState] = 0
};
if (weights is null)
return;
foreach (var (name, weight) in weights)
_weights[name] = weight;
}
public int WeightOf(string? name) =>
!string.IsNullOrEmpty(name) && _weights.TryGetValue(name, out int weight) ? weight : DefaultWeight;
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
/// <summary>
/// <see cref="ISceneryTranslations"/> reading the JSON files a scenery may ship
/// in its i18n folder.
/// </summary>
/// <remarks>
/// English is loaded first and the user's language layered over it, so a partial
/// translation falls back per key rather than per file. Nested objects are
/// flattened to dotted keys, which is how the scenery text references them.
/// </remarks>
public sealed class SceneryTranslations : ISceneryTranslations
{
/// <summary>Marks a scenery string as a key rather than literal text.</summary>
private const char ReferencePrefix = '@';
private const string BaseLanguage = "en";
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
private readonly Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
public SceneryTranslations(IFileSystem files, IDiagnosticsLog log)
{
_files = files;
_log = log;
}
public void LoadFor(Scenery scenery, string langCode)
{
_map.Clear();
string sceneryDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
string id = Path.GetFileNameWithoutExtension(scenery.Path);
string i18nDir = Path.Combine(sceneryDir, "i18n");
string language = NormalizeLanguage(langCode);
TryLoad(Path.Combine(i18nDir, $"{id}_{BaseLanguage}.json"));
if (!string.Equals(language, BaseLanguage, StringComparison.OrdinalIgnoreCase))
TryLoad(Path.Combine(i18nDir, $"{id}_{language}.json"));
}
public string Translate(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return text ?? "";
string trimmed = text.TrimStart();
if (trimmed.Length == 0 || trimmed[0] != ReferencePrefix)
return text;
string key = trimmed[1..].Trim();
return _map.TryGetValue(key, out string? translated) ? translated : text;
}
private void TryLoad(string path)
{
if (!_files.FileExists(path))
return;
try
{
using var document = JsonDocument.Parse(_files.ReadAllText(path));
if (document.RootElement.ValueKind == JsonValueKind.Object)
Flatten("", document.RootElement);
}
catch (Exception ex)
{
_log.Log($"scenery i18n/{Path.GetFileName(path)}", ex);
}
}
private void Flatten(string prefix, JsonElement element)
{
foreach (var property in element.EnumerateObject())
{
string key = string.IsNullOrEmpty(prefix) ? property.Name : prefix + "." + property.Name;
switch (property.Value.ValueKind)
{
case JsonValueKind.String:
_map[key] = property.Value.GetString() ?? "";
break;
case JsonValueKind.Number:
case JsonValueKind.True:
case JsonValueKind.False:
_map[key] = property.Value.ToString();
break;
case JsonValueKind.Object:
Flatten(key, property.Value);
break;
}
}
}
/// <summary>
/// Sceneries name their files by two-letter code; the starter's language
/// setting may carry a display name instead.
/// </summary>
private static string NormalizeLanguage(string language)
{
string lower = language.Trim().ToLowerInvariant();
return lower switch
{
"polski" or "pl" => "pl",
"english" or "en" => "en",
"čeština" or "cesky" or "czech" or "cs" or "cz" => "cz",
"magyar" or "hungarian" or "hu" => "hu",
"русский" or "russian" or "ru" => "ru",
_ => lower.Length >= 2 ? lower[..2] : "en"
};
}
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Infrastructure.Vehicles;
/// <summary>
/// Reads data/load_weights.txt into a <see cref="LoadWeightsTable"/>.
/// </summary>
/// <remarks>
/// The file is a brace-delimited list of "name : weight" pairs with '#', ';' and
/// '//' comments. It is read once and kept, since it does not change while the
/// starter runs.
/// </remarks>
public sealed class LoadWeightsRepository
{
private const string FileName = "load_weights.txt";
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
private readonly object _gate = new();
private LoadWeightsTable? _table;
public LoadWeightsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
{
_files = files;
_paths = paths;
_log = log;
}
public LoadWeightsTable Table
{
get
{
lock (_gate)
return _table ??= Read();
}
}
private LoadWeightsTable Read()
{
string path = Path.Combine(_paths.Data, FileName);
if (!_files.FileExists(path))
return new LoadWeightsTable();
try
{
return new LoadWeightsTable(ParsePairs(_files.ReadAllText(path, LegacyEncoding())));
}
catch (Exception ex)
{
_log.Log($"data/{FileName}", ex);
return new LoadWeightsTable();
}
}
private static Dictionary<string, int> ParsePairs(string text)
{
var body = new StringBuilder();
foreach (string raw in text.Split('\n'))
{
string line = raw;
int comment = line.IndexOfAny(new[] { '#', ';' });
if (comment >= 0)
line = line[..comment];
int slashes = line.IndexOf("//", StringComparison.Ordinal);
if (slashes >= 0)
line = line[..slashes];
body.Append(line).Append(' ');
}
// Colons may be written tight against their neighbours, so space them out
// before tokenising.
string[] tokens = body.Replace(":", " : ").ToString()
.Split(new[] { ' ', '\t', '\r' }, StringSplitOptions.RemoveEmptyEntries);
var weights = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 1; i + 1 < tokens.Length; i++)
{
if (tokens[i] != ":")
continue;
string name = tokens[i - 1];
if (name is "{" or "}")
continue;
if (int.TryParse(tokens[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int weight))
weights[name] = weight;
}
return weights;
}
private static Encoding LegacyEncoding()
{
try
{
return Encoding.GetEncoding(1250);
}
catch (Exception)
{
return Encoding.Latin1;
}
}
}

View File

@@ -76,7 +76,7 @@ public partial class MainWindow : Window
// why OnShortcutKey has to be careful about text entry and key capture. // why OnShortcutKey has to be careful about text entry and key capture.
AddHandler(KeyDownEvent, OnShortcutKey, RoutingStrategies.Tunnel); AddHandler(KeyDownEvent, OnShortcutKey, RoutingStrategies.Tunnel);
AppState.Instance.Changed += UpdateStartButton; AppServices.Current.State.Changed += UpdateStartButton;
Opened += (_, _) => Opened += (_, _) =>
{ {
@@ -342,8 +342,8 @@ public partial class MainWindow : Window
{ {
if (startButton is null) return; if (startButton is null) return;
var scenery = AppState.Instance.CurrentScenery; var scenery = AppServices.Current.State.CurrentScenery;
var trainset = AppState.Instance.CurrentTrainset; var trainset = AppServices.Current.State.CurrentTrainset;
if (scenery is null) if (scenery is null)
{ {
@@ -369,7 +369,7 @@ public partial class MainWindow : Window
static bool CanStart(Dynamic v) => static bool CanStart(Dynamic v) =>
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
string? selected = AppState.Instance.StartingVehicleName; string? selected = AppServices.Current.State.StartingVehicleName;
if (!string.IsNullOrEmpty(selected)) if (!string.IsNullOrEmpty(selected))
{ {
var pick = trainset.Vehicles.FirstOrDefault(v => var pick = trainset.Vehicles.FirstOrDefault(v =>
@@ -415,10 +415,10 @@ public partial class MainWindow : Window
private async Task LaunchAsync(bool saveSettings, bool freeFly) private async Task LaunchAsync(bool saveSettings, bool freeFly)
{ {
var scenery = AppState.Instance.CurrentScenery; var scenery = AppServices.Current.State.CurrentScenery;
if (scenery is null) if (scenery is null)
return; return;
var trainset = AppState.Instance.CurrentTrainset; var trainset = AppServices.Current.State.CurrentTrainset;
if (trainset is null) if (trainset is null)
return; return;
@@ -438,10 +438,10 @@ public partial class MainWindow : Window
} }
string? vehicle = TrainsetDisplay.UniquifyForLaunch( string? vehicle = TrainsetDisplay.UniquifyForLaunch(
trainset, scenery, AppState.Instance.StartingVehicleName); trainset, scenery, AppServices.Current.State.StartingVehicleName);
if (string.IsNullOrEmpty(vehicle)) if (string.IsNullOrEmpty(vehicle))
vehicle = ResolveStartVehicle(trainset); vehicle = ResolveStartVehicle(trainset);
AppState.Instance.StartingVehicleName = vehicle; AppServices.Current.State.StartingVehicleName = vehicle;
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
@@ -508,7 +508,7 @@ public partial class MainWindow : Window
static bool CanStart(Dynamic v) => static bool CanStart(Dynamic v) =>
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
string? selected = AppState.Instance.StartingVehicleName; string? selected = AppServices.Current.State.StartingVehicleName;
if (!string.IsNullOrEmpty(selected)) if (!string.IsNullOrEmpty(selected))
{ {
var pick = trainset.Vehicles.FirstOrDefault(v => var pick = trainset.Vehicles.FirstOrDefault(v =>

View File

@@ -146,7 +146,7 @@ public partial class Depot : UserControl
_drag.VehicleListPressed(sender, e); _drag.VehicleListPressed(sender, e);
private Dynamic NewVehicle(VehicleTexture texture) => private Dynamic NewVehicle(VehicleTexture texture) =>
_consist.MakeDynamic(texture, _listScenery ?? AppState.Instance.CurrentScenery); _consist.MakeDynamic(texture, _listScenery ?? AppServices.Current.State.CurrentScenery);
private void SyncFromSelection() private void SyncFromSelection()
{ {
@@ -155,7 +155,7 @@ public partial class Depot : UserControl
PopulateSceneryConsists(); PopulateSceneryConsists();
var trainset = AppState.Instance.CurrentTrainset; var trainset = AppServices.Current.State.CurrentTrainset;
if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset)) if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset))
_consist.LoadFrom(trainset); _consist.LoadFrom(trainset);
else if (_consist.EditingTrainset != null) else if (_consist.EditingTrainset != null)
@@ -220,7 +220,7 @@ public partial class Depot : UserControl
_consist.EditingTrainset, _consist.EditingTrainset,
_info.PhysicsFor, _info.PhysicsFor,
car => _db.TextureForSkin(car.SkinFile) is { } t ? VehicleInfo.CategoryOf(t) : null, car => _db.TextureForSkin(car.SkinFile) is { } t ? VehicleInfo.CategoryOf(t) : null,
LoadWeights.WeightOf)); AppServices.Current.LoadWeights.Table.WeightOf));
} }
private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -245,7 +245,7 @@ public partial class Depot : UserControl
depotShowAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles; depotShowAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
depotDrivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly; depotDrivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
_listScenery = AppState.Instance.CurrentScenery ?? _sceneries.FirstOrDefault(); _listScenery = AppServices.Current.State.CurrentScenery ?? _sceneries.FirstOrDefault();
sceneryConsistsTab.Header = App.Loc["SceneryConsists"]; sceneryConsistsTab.Header = App.Loc["SceneryConsists"];
ToolTip.SetTip(sceneryConsistsTab, ToolTip.SetTip(sceneryConsistsTab,
_listScenery != null ? Path.GetFileNameWithoutExtension(_listScenery.Path) : App.Loc["NoSceneryConsists"]); _listScenery != null ? Path.GetFileNameWithoutExtension(_listScenery.Path) : App.Loc["NoSceneryConsists"]);
@@ -276,7 +276,7 @@ public partial class Depot : UserControl
randomTurn: ToolRandomOrientation); randomTurn: ToolRandomOrientation);
sceneryConsistList.Items.Add(entry); sceneryConsistList.Items.Add(entry);
if (ReferenceEquals(trainset, AppState.Instance.CurrentTrainset)) if (ReferenceEquals(trainset, AppServices.Current.State.CurrentTrainset))
sceneryConsistList.SelectedItem = entry; sceneryConsistList.SelectedItem = entry;
} }
} }
@@ -289,9 +289,9 @@ public partial class Depot : UserControl
if (sceneryConsistList.SelectedItem is not ListBoxItem { Tag: Trainset trainset }) if (sceneryConsistList.SelectedItem is not ListBoxItem { Tag: Trainset trainset })
return; return;
AppState.Instance.CurrentScenery = _listScenery; AppServices.Current.State.CurrentScenery = _listScenery;
AppState.Instance.CurrentTrainset = trainset; AppServices.Current.State.CurrentTrainset = trainset;
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(trainset); AppServices.Current.State.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(trainset);
_consist.LoadFrom(trainset); _consist.LoadFrom(trainset);
} }
@@ -313,8 +313,8 @@ public partial class Depot : UserControl
private void ApplyConsist(Trainset target, List<Dynamic> vehicles) private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
{ {
target.Vehicles = vehicles; target.Vehicles = vehicles;
AppState.Instance.CurrentScenery = _listScenery; AppServices.Current.State.CurrentScenery = _listScenery;
AppState.Instance.CurrentTrainset = target; AppServices.Current.State.CurrentTrainset = target;
_consist.LoadFrom(target); _consist.LoadFrom(target);
PopulateSceneryConsists(); PopulateSceneryConsists();
} }
@@ -322,8 +322,8 @@ public partial class Depot : UserControl
private void RemoveAllVehicles(Trainset target) private void RemoveAllVehicles(Trainset target)
{ {
target.Vehicles = new List<Dynamic>(); target.Vehicles = new List<Dynamic>();
AppState.Instance.CurrentScenery = _listScenery; AppServices.Current.State.CurrentScenery = _listScenery;
AppState.Instance.CurrentTrainset = target; AppServices.Current.State.CurrentTrainset = target;
_consist.LoadFrom(target); _consist.LoadFrom(target);
PopulateSceneryConsists(); PopulateSceneryConsists();
} }
@@ -355,7 +355,7 @@ public partial class Depot : UserControl
public async Task RandomizeCurrentTrainsetAsync() public async Task RandomizeCurrentTrainsetAsync()
{ {
if (AppState.Instance.CurrentTrainset is not { } trainset) if (AppServices.Current.State.CurrentTrainset is not { } trainset)
return; return;
if (!ReferenceEquals(trainset, _consist.EditingTrainset)) if (!ReferenceEquals(trainset, _consist.EditingTrainset))

View File

@@ -13,6 +13,7 @@ using StarterNG.Classes;
using StarterNG.Controls; using StarterNG.Controls;
using StarterNG.Domain; using StarterNG.Domain;
using StarterNG.Domain.Vehicles; using StarterNG.Domain.Vehicles;
using StarterNG.Application;
namespace StarterNG.Views; namespace StarterNG.Views;
@@ -277,7 +278,7 @@ public sealed class VehicleDetails
if (ReferenceEquals(_consist.Selected, item)) if (ReferenceEquals(_consist.Selected, item))
_consist.SyncStartingVehicle(); _consist.SyncStartingVehicle();
_redraw(); _redraw();
AppState.Instance.NotifyChanged(); AppServices.Current.State.NotifyChanged();
}; };
driver.MinWidth = 0; driver.MinWidth = 0;
driver.HorizontalAlignment = HorizontalAlignment.Stretch; driver.HorizontalAlignment = HorizontalAlignment.Stretch;
@@ -617,7 +618,7 @@ public sealed class VehicleDetails
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 120 }; var typeCombo = new ComboBox { FontSize = 12, MinWidth = 120 };
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null }); typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
foreach (var name in available) foreach (var name in available)
typeCombo.Items.Add(new ComboBoxItem { Content = LoadWeights.Describe(name), Tag = name }); typeCombo.Items.Add(new ComboBoxItem { Content = DescribeLoad(name), Tag = name });
typeCombo.SelectedIndex = 0; typeCombo.SelectedIndex = 0;
if (!string.IsNullOrEmpty(lead.LoadType)) if (!string.IsNullOrEmpty(lead.LoadType))
@@ -754,4 +755,14 @@ public sealed class VehicleDetails
countRow, countRow,
BuildVehicleLoadTools(item)); BuildVehicleLoadTools(item));
} }
/// <summary>
/// Label for a cargo type. The pantograph state is carried as a pseudo load
/// and gets a translated name rather than its raw token.
/// </summary>
private static string DescribeLoad(string name) =>
string.Equals(name, Dynamic.PantState, StringComparison.OrdinalIgnoreCase)
? App.Loc["LoadPantState"]
: name;
} }

View File

@@ -76,7 +76,7 @@ public partial class Scenarios : UserControl
continue; continue;
bool expand = StarterNG.Classes.Settings.Instance.AutoExpandSceneryTree; bool expand = StarterNG.Classes.Settings.Instance.AutoExpandSceneryTree;
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode); AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
string label = scenery.DisplayName; string label = scenery.DisplayName;
if (string.IsNullOrEmpty(scenery.Group)) if (string.IsNullOrEmpty(scenery.Group))
@@ -93,7 +93,7 @@ public partial class Scenarios : UserControl
{ {
groupNode = new TreeViewItem groupNode = new TreeViewItem
{ {
Header = SceneryI18n.T(scenery.Group), Header = AppServices.Current.SceneryTexts.Translate(scenery.Group),
IsExpanded = expand IsExpanded = expand
}; };
groupNodes[scenery.Group] = groupNode; groupNodes[scenery.Group] = groupNode;
@@ -261,7 +261,7 @@ public partial class Scenarios : UserControl
{ {
trainset.Vehicles = vehicles; trainset.Vehicles = vehicles;
item.Content = TrainsetListContent(trainset); item.Content = TrainsetListContent(trainset);
if (ReferenceEquals(AppState.Instance.CurrentTrainset, trainset)) if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
ShowConsist(trainset); ShowConsist(trainset);
} }
@@ -309,8 +309,8 @@ public partial class Scenarios : UserControl
private void ShowSceneryInfo(Scenery scenery) private void ShowSceneryInfo(Scenery scenery)
{ {
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode); AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription; sceneryDescription.Text = scenery.LocalizedDescription(AppServices.Current.SceneryTexts);
string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery); string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
if (imagePath is not null) if (imagePath is not null)
@@ -401,7 +401,7 @@ public partial class Scenarios : UserControl
public void RebuildAfterLanguageChange() public void RebuildAfterLanguageChange()
{ {
string? selected = AppState.Instance.CurrentScenery?.Path; string? selected = AppServices.Current.State.CurrentScenery?.Path;
BuildSceneryTree(); BuildSceneryTree();
if (!string.IsNullOrEmpty(selected)) if (!string.IsNullOrEmpty(selected))
{ {
@@ -409,8 +409,8 @@ public partial class Scenarios : UserControl
Path.GetFileNameWithoutExtension(selected); Path.GetFileNameWithoutExtension(selected);
RestoreLastScenery(); RestoreLastScenery();
} }
if (AppState.Instance.CurrentScenery != null) if (AppServices.Current.State.CurrentScenery != null)
ShowSceneryInfo(AppState.Instance.CurrentScenery); ShowSceneryInfo(AppServices.Current.State.CurrentScenery);
RefreshSelectedConsist(); RefreshSelectedConsist();
} }
@@ -422,7 +422,7 @@ public partial class Scenarios : UserControl
_reloading = true; _reloading = true;
try try
{ {
string? selected = AppState.Instance.CurrentScenery?.Path; string? selected = AppServices.Current.State.CurrentScenery?.Path;
await Task.Run(() => AppServices.Current.Library.ReloadSceneries()); await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
Sceneries = AppServices.Current.Library.Sceneries; Sceneries = AppServices.Current.Library.Sceneries;
BuildSceneryTree(); BuildSceneryTree();
@@ -443,14 +443,14 @@ public partial class Scenarios : UserControl
private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e) private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e)
{ {
if (AppState.Instance.CurrentScenery is { } scenery) if (AppServices.Current.State.CurrentScenery is { } scenery)
RandomizeTexturesRequested?.Invoke(scenery); RandomizeTexturesRequested?.Invoke(scenery);
} }
public void RefreshConsistView() public void RefreshConsistView()
{ {
RefreshVehicleLabels(); RefreshVehicleLabels();
if (AppState.Instance.CurrentTrainset is { } trainset) if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset); ShowConsist(trainset);
} }
@@ -505,7 +505,7 @@ public partial class Scenarios : UserControl
private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e) private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e)
{ {
var scn = AppState.Instance.CurrentScenery; var scn = AppServices.Current.State.CurrentScenery;
string dir = scn != null string dir = scn != null
? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery") ? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
: Path.GetFullPath("scenery"); : Path.GetFullPath("scenery");
@@ -538,9 +538,9 @@ public partial class Scenarios : UserControl
return; return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag]; Trainset selectedTrainset = selectedScn.Trainsets[vTag];
AppState.Instance.CurrentScenery = selectedScn; AppServices.Current.State.CurrentScenery = selectedScn;
AppState.Instance.CurrentTrainset = selectedTrainset; AppServices.Current.State.CurrentTrainset = selectedTrainset;
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset); AppServices.Current.State.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
ShowConsist(selectedTrainset); ShowConsist(selectedTrainset);
ShowTimetable(selectedScn, selectedTrainset); ShowTimetable(selectedScn, selectedTrainset);
@@ -874,13 +874,13 @@ public partial class Scenarios : UserControl
trainset.Vehicles.Remove(v); trainset.Vehicles.Remove(v);
bool wasPicked = !string.IsNullOrEmpty(v.Name) && bool wasPicked = !string.IsNullOrEmpty(v.Name) &&
string.Equals(v.Name, AppState.Instance.StartingVehicleName, string.Equals(v.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
if (wasPicked && trainset.Vehicles.Count > 0) if (wasPicked && trainset.Vehicles.Count > 0)
{ {
int next = Math.Clamp(gap, 0, trainset.Vehicles.Count - 1); int next = Math.Clamp(gap, 0, trainset.Vehicles.Count - 1);
AppState.Instance.StartingVehicleName = trainset.Vehicles[next].Name; AppServices.Current.State.StartingVehicleName = trainset.Vehicles[next].Name;
AppState.Instance.NotifyChanged(); AppServices.Current.State.NotifyChanged();
} }
RefreshVehicleLabels(); RefreshVehicleLabels();
@@ -909,7 +909,7 @@ public partial class Scenarios : UserControl
: MissingMiniBox(train.SkinFile, thumbH); : MissingMiniBox(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) && bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppState.Instance.StartingVehicleName, string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
var slot = new Border var slot = new Border
@@ -946,7 +946,7 @@ public partial class Scenarios : UserControl
string desc = trainset.Description ?? ""; string desc = trainset.Description ?? "";
if (desc.TrimStart().StartsWith('-')) if (desc.TrimStart().StartsWith('-'))
desc = desc.TrimStart()[1..].TrimStart(); desc = desc.TrimStart()[1..].TrimStart();
missionDescription.Text = SceneryI18n.T(desc); missionDescription.Text = AppServices.Current.SceneryTexts.Translate(desc);
UpdateTrainStats(trainset); UpdateTrainStats(trainset);
} }
@@ -995,8 +995,8 @@ public partial class Scenarios : UserControl
index >= clicked.Vehicles.Count) index >= clicked.Vehicles.Count)
return; return;
AppState.Instance.StartingVehicleName = clicked.Vehicles[index].Name; AppServices.Current.State.StartingVehicleName = clicked.Vehicles[index].Name;
AppState.Instance.NotifyChanged(); AppServices.Current.State.NotifyChanged();
ShowConsist(clicked); ShowConsist(clicked);
return; return;
} }
@@ -1204,7 +1204,7 @@ public partial class Scenarios : UserControl
private void RefreshSelectedConsist() private void RefreshSelectedConsist()
{ {
if (AppState.Instance.CurrentTrainset is { } trainset) if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset); ShowConsist(trainset);
} }
} }