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

View File

@@ -1,12 +1,22 @@
using System;
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 static AppState Instance { get; } = new();
/// <summary>Raised whenever any of the selections changes.</summary>
public event Action? Changed;
private Scenery? _currentScenery;

View File

@@ -6,6 +6,7 @@ using System.Linq;
using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles;
using StarterNG.Application;
namespace StarterNG.Domain;
@@ -170,7 +171,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
var driven = item.Cars.FirstOrDefault(c =>
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)
@@ -324,7 +325,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (ReferenceEquals(Selected, item))
SyncStartingVehicle();
Raise();
AppState.Instance.NotifyChanged();
AppServices.Current.State.NotifyChanged();
}
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 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.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
: string.Join("\n", Description.Split('\n').Select(translations.Translate));
/// <summary>
/// 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.
AddHandler(KeyDownEvent, OnShortcutKey, RoutingStrategies.Tunnel);
AppState.Instance.Changed += UpdateStartButton;
AppServices.Current.State.Changed += UpdateStartButton;
Opened += (_, _) =>
{
@@ -342,8 +342,8 @@ public partial class MainWindow : Window
{
if (startButton is null) return;
var scenery = AppState.Instance.CurrentScenery;
var trainset = AppState.Instance.CurrentTrainset;
var scenery = AppServices.Current.State.CurrentScenery;
var trainset = AppServices.Current.State.CurrentTrainset;
if (scenery is null)
{
@@ -369,7 +369,7 @@ public partial class MainWindow : Window
static bool CanStart(Dynamic v) =>
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))
{
var pick = trainset.Vehicles.FirstOrDefault(v =>
@@ -415,10 +415,10 @@ public partial class MainWindow : Window
private async Task LaunchAsync(bool saveSettings, bool freeFly)
{
var scenery = AppState.Instance.CurrentScenery;
var scenery = AppServices.Current.State.CurrentScenery;
if (scenery is null)
return;
var trainset = AppState.Instance.CurrentTrainset;
var trainset = AppServices.Current.State.CurrentTrainset;
if (trainset is null)
return;
@@ -438,10 +438,10 @@ public partial class MainWindow : Window
}
string? vehicle = TrainsetDisplay.UniquifyForLaunch(
trainset, scenery, AppState.Instance.StartingVehicleName);
trainset, scenery, AppServices.Current.State.StartingVehicleName);
if (string.IsNullOrEmpty(vehicle))
vehicle = ResolveStartVehicle(trainset);
AppState.Instance.StartingVehicleName = vehicle;
AppServices.Current.State.StartingVehicleName = vehicle;
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
@@ -508,7 +508,7 @@ public partial class MainWindow : Window
static bool CanStart(Dynamic v) =>
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))
{
var pick = trainset.Vehicles.FirstOrDefault(v =>

View File

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

View File

@@ -13,6 +13,7 @@ using StarterNG.Classes;
using StarterNG.Controls;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Application;
namespace StarterNG.Views;
@@ -277,7 +278,7 @@ public sealed class VehicleDetails
if (ReferenceEquals(_consist.Selected, item))
_consist.SyncStartingVehicle();
_redraw();
AppState.Instance.NotifyChanged();
AppServices.Current.State.NotifyChanged();
};
driver.MinWidth = 0;
driver.HorizontalAlignment = HorizontalAlignment.Stretch;
@@ -617,7 +618,7 @@ public sealed class VehicleDetails
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 120 };
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
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;
if (!string.IsNullOrEmpty(lead.LoadType))
@@ -754,4 +755,14 @@ public sealed class VehicleDetails
countRow,
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;
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;
if (string.IsNullOrEmpty(scenery.Group))
@@ -93,7 +93,7 @@ public partial class Scenarios : UserControl
{
groupNode = new TreeViewItem
{
Header = SceneryI18n.T(scenery.Group),
Header = AppServices.Current.SceneryTexts.Translate(scenery.Group),
IsExpanded = expand
};
groupNodes[scenery.Group] = groupNode;
@@ -261,7 +261,7 @@ public partial class Scenarios : UserControl
{
trainset.Vehicles = vehicles;
item.Content = TrainsetListContent(trainset);
if (ReferenceEquals(AppState.Instance.CurrentTrainset, trainset))
if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
ShowConsist(trainset);
}
@@ -309,8 +309,8 @@ public partial class Scenarios : UserControl
private void ShowSceneryInfo(Scenery scenery)
{
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription;
AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription(AppServices.Current.SceneryTexts);
string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
if (imagePath is not null)
@@ -401,7 +401,7 @@ public partial class Scenarios : UserControl
public void RebuildAfterLanguageChange()
{
string? selected = AppState.Instance.CurrentScenery?.Path;
string? selected = AppServices.Current.State.CurrentScenery?.Path;
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
@@ -409,8 +409,8 @@ public partial class Scenarios : UserControl
Path.GetFileNameWithoutExtension(selected);
RestoreLastScenery();
}
if (AppState.Instance.CurrentScenery != null)
ShowSceneryInfo(AppState.Instance.CurrentScenery);
if (AppServices.Current.State.CurrentScenery != null)
ShowSceneryInfo(AppServices.Current.State.CurrentScenery);
RefreshSelectedConsist();
}
@@ -422,7 +422,7 @@ public partial class Scenarios : UserControl
_reloading = true;
try
{
string? selected = AppState.Instance.CurrentScenery?.Path;
string? selected = AppServices.Current.State.CurrentScenery?.Path;
await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
Sceneries = AppServices.Current.Library.Sceneries;
BuildSceneryTree();
@@ -443,14 +443,14 @@ public partial class Scenarios : UserControl
private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e)
{
if (AppState.Instance.CurrentScenery is { } scenery)
if (AppServices.Current.State.CurrentScenery is { } scenery)
RandomizeTexturesRequested?.Invoke(scenery);
}
public void RefreshConsistView()
{
RefreshVehicleLabels();
if (AppState.Instance.CurrentTrainset is { } trainset)
if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset);
}
@@ -505,7 +505,7 @@ public partial class Scenarios : UserControl
private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e)
{
var scn = AppState.Instance.CurrentScenery;
var scn = AppServices.Current.State.CurrentScenery;
string dir = scn != null
? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
: Path.GetFullPath("scenery");
@@ -538,9 +538,9 @@ public partial class Scenarios : UserControl
return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag];
AppState.Instance.CurrentScenery = selectedScn;
AppState.Instance.CurrentTrainset = selectedTrainset;
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
AppServices.Current.State.CurrentScenery = selectedScn;
AppServices.Current.State.CurrentTrainset = selectedTrainset;
AppServices.Current.State.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
ShowConsist(selectedTrainset);
ShowTimetable(selectedScn, selectedTrainset);
@@ -874,13 +874,13 @@ public partial class Scenarios : UserControl
trainset.Vehicles.Remove(v);
bool wasPicked = !string.IsNullOrEmpty(v.Name) &&
string.Equals(v.Name, AppState.Instance.StartingVehicleName,
string.Equals(v.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase);
if (wasPicked && trainset.Vehicles.Count > 0)
{
int next = Math.Clamp(gap, 0, trainset.Vehicles.Count - 1);
AppState.Instance.StartingVehicleName = trainset.Vehicles[next].Name;
AppState.Instance.NotifyChanged();
AppServices.Current.State.StartingVehicleName = trainset.Vehicles[next].Name;
AppServices.Current.State.NotifyChanged();
}
RefreshVehicleLabels();
@@ -909,7 +909,7 @@ public partial class Scenarios : UserControl
: MissingMiniBox(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppState.Instance.StartingVehicleName,
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase);
var slot = new Border
@@ -946,7 +946,7 @@ public partial class Scenarios : UserControl
string desc = trainset.Description ?? "";
if (desc.TrimStart().StartsWith('-'))
desc = desc.TrimStart()[1..].TrimStart();
missionDescription.Text = SceneryI18n.T(desc);
missionDescription.Text = AppServices.Current.SceneryTexts.Translate(desc);
UpdateTrainStats(trainset);
}
@@ -995,8 +995,8 @@ public partial class Scenarios : UserControl
index >= clicked.Vehicles.Count)
return;
AppState.Instance.StartingVehicleName = clicked.Vehicles[index].Name;
AppState.Instance.NotifyChanged();
AppServices.Current.State.StartingVehicleName = clicked.Vehicles[index].Name;
AppServices.Current.State.NotifyChanged();
ShowConsist(clicked);
return;
}
@@ -1204,7 +1204,7 @@ public partial class Scenarios : UserControl
private void RefreshSelectedConsist()
{
if (AppState.Instance.CurrentTrainset is { } trainset)
if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset);
}
}