mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
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:
41
StarterNG.Tests/LoadWeightsTests.cs
Normal file
41
StarterNG.Tests/LoadWeightsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
52
StarterNG.Tests/SceneryTranslationsTests.cs
Normal file
52
StarterNG.Tests/SceneryTranslationsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
23
StarterNG/Application/Abstractions/ISceneryTranslations.cs
Normal file
23
StarterNG/Application/Abstractions/ISceneryTranslations.cs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
40
StarterNG/Domain/Vehicles/LoadWeightsTable.cs
Normal file
40
StarterNG/Domain/Vehicles/LoadWeightsTable.cs
Normal 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;
|
||||
}
|
||||
119
StarterNG/Infrastructure/Sceneries/SceneryTranslations.cs
Normal file
119
StarterNG/Infrastructure/Sceneries/SceneryTranslations.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
114
StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs
Normal file
114
StarterNG/Infrastructure/Vehicles/LoadWeightsRepository.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user