diff --git a/StarterNG.Tests/Fakes/StubRandom.cs b/StarterNG.Tests/Fakes/StubRandom.cs new file mode 100644 index 0000000..cbb91e4 --- /dev/null +++ b/StarterNG.Tests/Fakes/StubRandom.cs @@ -0,0 +1,22 @@ +using StarterNG.Application.Abstractions; + +namespace StarterNG.Tests.Fakes; + +/// +/// Randomness pinned to the low end of every range, so weather that varies in +/// production is deterministic in tests. +/// +public sealed class StubRandom : IRandomSource +{ + private readonly int _fixedValue; + + public StubRandom(int fixedValue = 0) + { + _fixedValue = fixedValue; + } + + public int Next(int minInclusive, int maxExclusive) => + Math.Clamp(minInclusive + _fixedValue, minInclusive, Math.Max(minInclusive, maxExclusive - 1)); + + public double NextDouble() => 0.0; +} diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs new file mode 100644 index 0000000..4fdbb1c --- /dev/null +++ b/StarterNG.Tests/SceneryExportTests.cs @@ -0,0 +1,146 @@ +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryExportTests +{ + private static SceneryParser Parser() => + new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom()); + + [Fact] + public void An_untouched_scenery_exports_its_trainsets_back_verbatim() + { + const string scn = """ + //$n Test + trainset pociag tor_1 10 0 + node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0 + endtrainset + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("{{0}}", exported); + Assert.Contains("trainset pociag tor_1", exported); + Assert.Contains("endtrainset", exported); + } + + [Fact] + public void Selected_optional_includes_are_written_back_before_FirstInit() + { + const string scn = """ + include zima.inc end //$optional 1, Zima + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("include zima.inc end", exported); + Assert.True(exported.IndexOf("include zima.inc", StringComparison.Ordinal) < + exported.IndexOf("FirstInit", StringComparison.Ordinal)); + } + + [Fact] + public void A_deselected_optional_include_stays_out() + { + const string scn = """ + include zima.inc end //$optional 1, Zima + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + scenery.Includes[0].Selected = false; + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("include zima.inc", exported); + } + + [Fact] + public void A_kind_two_include_is_written_only_without_a_companion_timetable() + { + const string scn = """ + include rozklad.inc end //$optional 2, Rozkład + x + endoptional + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + scenery.HasCompanionTimetable = false; + Assert.Contains("include rozklad.inc", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + + scenery.HasCompanionTimetable = true; + Assert.DoesNotContain("include rozklad.inc", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + } + + [Fact] + public void Edited_weather_replaces_the_authored_config_block() + { + const string scn = """ + config + movelight 100 + scenario.weather.temperature 5 + eventlauncher.range 200 + endconfig + atmo 0 0 0 10 2000 0 0 0 0 endatmo + time 8:30 0 0 endtime + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + scenery.Weather.Day = 200; + scenery.Weather.Temperature = 25; + scenery.Weather.Dirty = true; + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("movelight 200", exported); + Assert.Contains("scenario.weather.temperature 25", exported); + Assert.DoesNotContain("movelight 100", exported); + // Config the scenery author wrote and the starter does not manage survives. + Assert.Contains("eventlauncher.range 200", exported); + } + + [Fact] + public void Decor_trainsets_can_be_dropped_from_the_export() + { + const string scn = """ + //$n Test + trainset ai_1 tor_1 10 0 //$decor + node -1 -1 ai-1 dynamic PKP/EP07 ep07 ep07.mmd 0 nobody 0 + endtrainset + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.True(scenery.Trainsets[0].Decor); + Assert.DoesNotContain("trainset ai_1", + scenery.BuildExportContent(skipDecorTrainsets: true, new StubRandom())); + Assert.Contains("trainset ai_1", + scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + } + + [Fact] + public void Loose_vehicles_are_appended_as_nodes_again() + { + const string scn = """ + node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic + FirstInit + """; + var scenery = Parser().Parse("scenery/x.scn", scn); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.Contains("wagon_1", exported); + Assert.Contains("enddynamic", exported); + } +} diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs new file mode 100644 index 0000000..5a5dd84 --- /dev/null +++ b/StarterNG.Tests/SceneryParserTests.cs @@ -0,0 +1,154 @@ +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryParserTests +{ + private static SceneryParser Parser(DateTime? now = null) => + new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom()); + + [Fact] + public void Directives_become_the_scenery_headline() + { + const string scn = """ + //$l Śląsk + //$n Osobowy do Katowic + //$d Pierwszy opis + //$d drugi wiersz + //$i images/kato.jpg + """; + + var scenery = Parser().Parse("scenery/kato.scn", scn); + + Assert.Equal("Śląsk", scenery.Group); + Assert.Equal("Osobowy do Katowic", scenery.Name); + Assert.Equal("Pierwszy opis\ndrugi wiersz", scenery.Description); + Assert.Equal("images/kato.jpg", scenery.ImageName); + Assert.False(scenery.Archival); + Assert.Equal("kato", scenery.DisplayName); + } + + [Fact] + public void The_a_directive_marks_a_scenery_archival() + { + var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n"); + + Assert.True(scenery.Archival); + } + + [Fact] + public void Attachments_take_a_label_from_the_rest_of_the_line() + { + const string scn = """ + //$f 01 doc/rozklad.pdf Rozkład jazdy + //$f doc/mapa.png Mapa + //$f doc/notes.txt + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.Collection(scenery.Attachments, + first => + { + Assert.Equal("doc/rozklad.pdf", first.FilePath); + Assert.Equal("Rozkład jazdy", first.Label); + }, + second => + { + Assert.Equal("doc/mapa.png", second.FilePath); + Assert.Equal("Mapa", second.Label); + }, + third => + { + Assert.Equal("doc/notes.txt", third.FilePath); + Assert.Equal("notes.txt", third.Label); + }); + } + + [Fact] + public void Optional_includes_are_lifted_out_of_the_template() + { + const string scn = """ + include zima.inc end //$optional 1, Sceneria zimowa + some content + endoptional + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var include = Assert.Single(scenery.Includes); + Assert.Equal("zima.inc", include.FilePath); + Assert.Equal("Sceneria zimowa", include.Desc); + Assert.Equal(1, include.Kind); + Assert.True(include.Selected); + Assert.DoesNotContain("zima.inc", scenery.Template); + } + + [Fact] + public void Includes_of_kind_two_are_off_until_export_decides() + { + const string scn = """ + include rozklad.inc end //$optional 2, Rozkład wbudowany + x + endoptional + """; + + var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes); + + Assert.Equal(2, include.Kind); + Assert.False(include.Selected); + } + + [Fact] + public void Trainsets_are_parsed_and_replaced_by_placeholders() + { + const string scn = """ + //$n Test + trainset pociag tor_1 10 0 + node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0 + endtrainset + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var trainset = Assert.Single(scenery.Trainsets); + Assert.True(trainset.Parsed); + Assert.Equal("pociag", trainset.Name); + Assert.Equal("tor_1", trainset.Track); + Assert.Contains("{{0}}", scenery.Template); + Assert.DoesNotContain("endtrainset", scenery.Template); + } + + [Fact] + public void Vehicles_outside_a_trainset_are_collected_as_loose() + { + const string scn = """ + node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic + FirstInit + """; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + var vehicle = Assert.Single(scenery.LooseVehicles); + Assert.Equal("wagon_1", vehicle.Name); + Assert.Equal("PKP/111A", vehicle.DataFolder); + Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames); + Assert.DoesNotContain("enddynamic", scenery.Template); + } + + [Fact] + public void A_malformed_loose_vehicle_is_reported_and_left_in_place() + { + const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n"; + + var scenery = Parser().Parse("scenery/x.scn", scn); + + Assert.Empty(scenery.LooseVehicles); + Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames); + Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1")); + Assert.Contains("enddynamic", scenery.Template); + } +} diff --git a/StarterNG.Tests/SceneryRepositoryTests.cs b/StarterNG.Tests/SceneryRepositoryTests.cs new file mode 100644 index 0000000..facaa7e --- /dev/null +++ b/StarterNG.Tests/SceneryRepositoryTests.cs @@ -0,0 +1,101 @@ +using System.Text; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryRepositoryTests +{ + private static SceneryRepository Build(InMemoryFileSystem files, out FileDiagnosticsLog log) + { + var paths = new GamePaths("game"); + var clock = new FixedClock(new DateTime(2026, 1, 1)); + log = new FileDiagnosticsLog(files, clock, paths); + return new SceneryRepository(files, paths, log, new SceneryParser(clock, new StubRandom())); + } + + [Fact] + public void Every_scn_in_the_scenery_folder_is_loaded() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice") + .WithFile(Path.Combine("game", "scenery", "gdansk.scn"), "//$n Gdańsk"); + + var loaded = Build(files, out _).LoadAll(); + + Assert.Equal(2, loaded.Count); + Assert.Contains(loaded, scenery => scenery.Name == "Katowice"); + } + + [Fact] + public void Authoring_scratch_files_and_other_extensions_are_skipped() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "$wip.scn"), "//$n Robocza") + .WithFile(Path.Combine("game", "scenery", "kato.inc"), "//$n Include") + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice"); + + var loaded = Build(files, out _).LoadAll(); + + Assert.Equal("Katowice", Assert.Single(loaded).Name); + } + + [Fact] + public void A_companion_sbt_is_noticed_when_the_scenery_is_loaded() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice") + .WithFile(Path.Combine("game", "scenery", "kato.sbt"), "timetable"); + + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + Assert.True(scenery.HasCompanionTimetable); + } + + [Fact] + public void A_missing_scenery_loads_as_null_rather_than_throwing() + { + Assert.Null(Build(new InMemoryFileSystem(), out _).Load(Path.Combine("game", "scenery", "gone.scn"))); + } + + [Fact] + public void Progress_is_reported_once_per_file() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "a.scn"), "//$n A") + .WithFile(Path.Combine("game", "scenery", "b.scn"), "//$n B"); + var steps = new List(); + + Build(files, out _).LoadAll(new Progress( + step => { lock (steps) steps.Add(step.Loaded); })); + + // Progress posts asynchronously; give it a moment to drain. + SpinWait.SpinUntil(() => { lock (steps) return steps.Count == 2; }, TimeSpan.FromSeconds(2)); + lock (steps) + Assert.Equal(new[] { 1, 2 }, steps.OrderBy(value => value)); + } + + [Fact] + public void The_preview_image_is_found_next_to_the_scenery() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$i kato.jpg") + .WithFile(Path.Combine("game", "scenery", "images", "kato.jpg"), "binary"); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + string? image = new SceneryImageLocator(files).Resolve(scenery); + + Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image); + } + + [Fact] + public void A_scenery_without_an_image_directive_resolves_to_nothing() + { + var files = new InMemoryFileSystem() + .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice"); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + Assert.Null(new SceneryImageLocator(files).Resolve(scenery)); + } +} diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs new file mode 100644 index 0000000..4e968c4 --- /dev/null +++ b/StarterNG.Tests/SceneryWeatherTests.cs @@ -0,0 +1,87 @@ +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryWeatherTests +{ + private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom()); + + [Fact] + public void Authored_weather_is_read_from_the_config_block() + { + const string scn = """ + config + movelight 172 + scenario.weather.temperature 21.5 + scenario.time.override 6:05 + endconfig + atmo 0 0 0 50 900 0 0 0 0.7 endatmo + time 8:30 0 0 endtime + """; + + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather; + + Assert.True(weather.IsAuthored); + Assert.Equal(172, weather.Day); + Assert.Equal(21.5, weather.Temperature); + Assert.Equal("06:05", weather.ScenarioTimeOverride); + Assert.Equal("08:30", weather.Time); + Assert.Equal(50, weather.FogStart); + Assert.Equal(0.7, weather.Overcast); + } + + [Fact] + public void A_scenery_without_a_time_starts_at_the_players_clock() + { + var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather; + + Assert.Equal("17:42", weather.ScenarioTimeOverride); + Assert.False(weather.IsAuthored); + } + + [Fact] + public void Freshly_parsed_weather_counts_as_unchanged() + { + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; + + Assert.False(weather.Changed); + } + + [Fact] + public void Editing_then_restoring_returns_to_the_authored_values() + { + var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; + + weather.Day = 5; + weather.Temperature = -12; + Assert.True(weather.Changed); + + weather.Restore(); + + Assert.False(weather.Changed); + Assert.Equal(100, weather.Day); + Assert.True(weather.Dirty); + } + + [Fact] + public void A_fog_range_is_clamped_into_the_supported_span() + { + var weather = new SceneryWeather(); + + weather.SetFogRange(start: 1, end: 999999, random: new StubRandom()); + + Assert.Equal(SceneryWeather.FogMin, weather.FogStart); + Assert.InRange(weather.FogEnd, SceneryWeather.FogMin, SceneryWeather.FogMax); + } + + [Fact] + public void Random_overcast_draws_from_the_presets_rather_than_the_stored_value() + { + var weather = new SceneryWeather { Overcast = 0.42, OvercastRandom = true }; + + Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom())); + Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom())); + } +} diff --git a/StarterNG/Application/Abstractions/IRandomSource.cs b/StarterNG/Application/Abstractions/IRandomSource.cs new file mode 100644 index 0000000..f5f57b2 --- /dev/null +++ b/StarterNG/Application/Abstractions/IRandomSource.cs @@ -0,0 +1,15 @@ +namespace StarterNG.Application.Abstractions; + +/// +/// Port over randomness. Scenery weather deliberately varies between runs (fog +/// distance, overcast presets, texture randomiser); routing it through a port +/// keeps those rules assertable instead of flaky. +/// +public interface IRandomSource +{ + /// Inclusive lower bound, exclusive upper bound. + int Next(int minInclusive, int maxExclusive); + + /// Uniform value in [0, 1). + double NextDouble(); +} diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs new file mode 100644 index 0000000..f923fc7 --- /dev/null +++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Application.Abstractions; + +/// How far a scenery sweep has got, for the splash screen. +public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName); + +/// +/// Reads the scenarios of an installation. The only route from a .scn on disk to +/// a ; nothing else constructs one from a path. +/// +public interface ISceneryRepository +{ + /// + /// Every scenario in the installation, in no particular order. Files that + /// fail to parse are logged and skipped rather than failing the load. + /// + IReadOnlyList LoadAll(IProgress? progress = null); + + /// Reloads a single scenario; null when it is gone or unreadable. + Scenery? Load(string path); +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 5540e6c..742aef5 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -1,6 +1,7 @@ using System; using StarterNG.Application.Abstractions; using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; using StarterNG.Services; namespace StarterNG.Application; @@ -33,8 +34,11 @@ public sealed class AppServices var log = new FileDiagnosticsLog(files, clock, paths); Log = log; + Random = new SystemRandomSource(); Processes = new SystemProcessLauncher(log); Localization = new LocalizationService(log); + Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random)); + SceneryImages = new SceneryImageLocator(files); } /// @@ -66,6 +70,12 @@ public sealed class AppServices public IProcessLauncher Processes { get; } + public IRandomSource Random { get; } + + public ISceneryRepository Sceneries { get; } + + public SceneryImageLocator SceneryImages { get; } + /// /// Exposed as the concrete service rather than /// because the XAML binds to it as a change-notifying source; code should take diff --git a/StarterNG/Classes/AppState.cs b/StarterNG/Classes/AppState.cs index 3b9bbec..d0f0845 100644 --- a/StarterNG/Classes/AppState.cs +++ b/StarterNG/Classes/AppState.cs @@ -1,4 +1,5 @@ -using System; +using System; +using StarterNG.Domain.Sceneries; namespace StarterNG.Classes; diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs index 348d8e5..938f57e 100644 --- a/StarterNG/Classes/GameData.cs +++ b/StarterNG/Classes/GameData.cs @@ -4,6 +4,9 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using StarterNG.Application; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; namespace StarterNG.Classes; @@ -26,17 +29,12 @@ public sealed class GameData public bool Loaded { get; private set; } public void Load(IProgress? progress = null, - string sceneryDir = "scenery/", string miniDir = "textures/mini/", string dynamicRoot = "dynamic") { if (Loaded) return; - var scnFiles = EnumerateScenery(sceneryDir); - int total = Math.Max(1, scnFiles.Count); - int done = 0; - progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot); if (vehicles == 0) @@ -46,61 +44,19 @@ public sealed class GameData () => VehicleDatabase.PreloadMiniIndex(miniDir), () => Physics.PreloadIndex()); - var sceneryLock = new object(); - var doneCount = done; - Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - file => - { - try - { - var scenery = new Scenery(file); - lock (sceneryLock) - { - Sceneries.Add(scenery); - doneCount++; - progress?.Report(new LoadStatus((double)doneCount / total, LoadPhase.Sceneries, - Path.GetFileName(file))); - } - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex); - } - }); - done = doneCount; + var sceneryProgress = progress is null + ? null + : new Progress(step => progress.Report( + new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName))); + ReloadSceneries(sceneryProgress); progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); Loaded = true; } - public void ReloadSceneries(string sceneryDir = "scenery/") + public void ReloadSceneries(IProgress? progress = null) { Sceneries.Clear(); - var scnFiles = EnumerateScenery(sceneryDir); - var sceneryLock = new object(); - Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - file => - { - try - { - var scenery = new Scenery(file); - lock (sceneryLock) - Sceneries.Add(scenery); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex); - } - }); - } - - private static List EnumerateScenery(string sceneryDir) - { - if (!Directory.Exists(sceneryDir)) - return new List(); - - return Directory.GetFiles(sceneryDir, "*.scn") - .Where(p => !Path.GetFileName(p).StartsWith("$")) - .ToList(); + Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress)); } } diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs deleted file mode 100644 index 2743994..0000000 --- a/StarterNG/Classes/Scenery.cs +++ /dev/null @@ -1,541 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using StarterNG.Domain; - -namespace StarterNG.Classes; - -public sealed class SceneryAttachment -{ - public string FilePath = ""; - public string Label = ""; -} - -public sealed class SceneryInclude -{ - public string FilePath = ""; - public string Desc = ""; - public int Kind; - public bool Selected; -} - -public class Scenery -{ - public List Lines; - public List Trainsets; - public List Attachments = new(); - public List Includes = new(); - - public List LooseVehicleNames = new(); - - public List LooseVehicles = new(); - - public List Faults = new(); - public string Group; - public string Path; - - public string Name; - public string Description; - public string ImageName; - - public bool Archival; - - public string Time = "10:30"; - public string ScenarioTimeOverride = "10:30"; - public int Day = 0; - public double Temperature = 15; - public int FogStart = 10; - public int FogEnd = 2000; - - public const int FogMin = 10; - public const int FogMax = 10000; - public double Overcast = 0; - public bool OvercastRandom; - - public bool HasWeather; - - public bool WeatherDirty; - - private string _origTime = "10:30"; - private string _origOverride = "10:30"; - private int _origDay; - private double _origTemperature = 15; - private int _origFogEnd = 2000; - private double _origOvercast; - private bool _origOvercastRandom; - - private readonly string _template; - - public string DisplayName - { - get - { - return System.IO.Path.GetFileNameWithoutExtension(Path); - } - } - - public string LocalizedDescription => - string.IsNullOrEmpty(Description) ? "" : - string.Join("\n", Description.Split('\n').Select(SceneryI18n.T)); - - private string _imagePath; - private bool _imagePathResolved; - - public Scenery(string path) - { - this.Path = path; - Trainsets = new List(); - if (!File.Exists(path)) - throw new FileNotFoundException(path); - var encoding = Encoding.GetEncoding(1250); - string content = File.ReadAllText(path, encoding); - - this.Group = MatchDirective(content, "l"); - this.Name = MatchDirective(content, "n"); - - this.Description = MatchAllDirectives(content, "d"); - this.ImageName = MatchDirective(content, "i"); - - this.Archival = MatchDirective(content, "a") != null; - - ParseWeather(content); - SnapshotWeather(); - - ParseAttachments(content); - content = ParseAndStripOptionalIncludes(content); - - List trainsetEntries = new List(); - Regex regex = new Regex( - @"trainset\b[\s\S]*?\bendtrainset\b", - RegexOptions.IgnoreCase - ); - int idx = 0; - content = regex.Replace(content, match => - { - trainsetEntries.Add(match.Value); - return $"{{{{{idx++}}}}}"; - }); - content = ExtractLooseVehicles(content); - _template = content; - - foreach (string trainsetEntry in trainsetEntries) - { - var ts = new Trainset(trainsetEntry); - Trainsets.Add(ts); - if (!ts.Parsed) - Faults.Add($"# trainset parse fault: {ts.Name} ({ts.Track})"); - } - } - - private string ExtractLooseVehicles(string content) - { - LooseVehicles.Clear(); - LooseVehicleNames.Clear(); - var inv = System.Globalization.CultureInfo.InvariantCulture; - - return Regex.Replace( - content, - @"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b", - m => - { - string name = m.Groups[3].Value; - if (name.StartsWith("{{", StringComparison.Ordinal)) - return m.Value; - try - { - var d = new Dynamic - { - RangeMax = float.Parse(m.Groups[1].Value, inv), - RangeMin = float.Parse(m.Groups[2].Value, inv), - Name = name, - DataFolder = m.Groups[4].Value, - SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value), - MmdFile = m.Groups[6].Value, - PathName = m.Groups[7].Value, - Offset = float.Parse(m.Groups[8].Value, inv), - DriverType = m.Groups[9].Value.ToLowerInvariant() switch - { - "headdriver" => eDriverType.Headdriver, - "reardriver" => eDriverType.Reardriver, - "passenger" => eDriverType.Passenger, - _ => eDriverType.Nobody - }, - HasVelocity = true, - Velocity = float.Parse(m.Groups[10].Value, inv) - }; - var trailing = m.Groups[11].Value - .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) - .ToList(); - d.ReadTrailing(trailing); - LooseVehicles.Add(d); - LooseVehicleNames.Add(d.Name); - return "\n"; - } - catch - { - Faults.Add($"# loose dynamic parse fault: {name}"); - LooseVehicleNames.Add(name); - return m.Value; - } - }); - } - - public string BuildExportContent(bool skipDecorTrainsets = false) - { - string result = _template; - - if (WeatherDirty) - result = RewriteWeather(result); - - result = InjectIncludes(result); - - for (int i = 0; i < Trainsets.Count; i++) - { - string entry = skipDecorTrainsets && Trainsets[i].Decor - ? string.Empty - : Trainsets[i].ToSceneryEntry(); - result = result.Replace("{{" + i + "}}", entry); - } - - if (LooseVehicles.Count > 0) - result = AppendLooseVehicles(result); - - return result; - } - - private string AppendLooseVehicles(string text) - { - var sb = new StringBuilder(); - foreach (var v in LooseVehicles) - sb.Append(v.ToLooseNode()); - if (sb.Length == 0) return text; - - var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase); - if (fi.Success) - return text[..fi.Index] + sb + text[fi.Index..]; - return text + sb; - } - - private void ParseAttachments(string content) - { - Attachments.Clear(); - foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline)) - { - string rest = m.Groups[1].Value.Trim(); - if (rest.Length == 0) continue; - string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length >= 3 && parts[0].Length <= 2) - Attachments.Add(new SceneryAttachment - { - FilePath = parts[1], - Label = string.Join(" ", parts.Skip(2)) - }); - else if (parts.Length >= 2) - Attachments.Add(new SceneryAttachment - { - FilePath = parts[0], - Label = string.Join(" ", parts.Skip(1)) - }); - else - Attachments.Add(new SceneryAttachment - { - FilePath = parts[0], - Label = System.IO.Path.GetFileName(parts[0]) - }); - } - } - - private string ParseAndStripOptionalIncludes(string content) - { - Includes.Clear(); - - if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0) - return content; - - return Regex.Replace( - content, - @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b", - m => - { - string incPath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " "); - string raw = m.Groups[2].Value.Trim().TrimStart(',', ' '); - string[] par = raw.Split(new[] { ',', '|', ';' }, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - int kind = 1; - string desc = incPath; - if (par.Length >= 1 && int.TryParse(par[0], out int k)) - kind = k; - if (par.Length >= 2) - desc = par[1]; - - Includes.Add(new SceneryInclude - { - FilePath = incPath, - Desc = desc, - Kind = kind, - Selected = kind == 1 - }); - return " "; - }, - RegexOptions.IgnoreCase); - } - - private string InjectIncludes(string text) - { - if (Includes.Count == 0) - return text; - - var sb = new StringBuilder(); - string sbt = System.IO.Path.ChangeExtension(Path, ".sbt"); - bool hasSbt = File.Exists(sbt); - - foreach (var inc in Includes) - { - if (inc.Kind == 2) - { - if (!hasSbt) - sb.Append("include ").Append(inc.FilePath).Append(" end\r\n"); - continue; - } - if (inc.Selected) - sb.Append("include ").Append(inc.FilePath).Append(" end\r\n"); - } - - if (sb.Length == 0) - return text; - - var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase); - if (fi.Success) - return text[..fi.Index] + sb + text[fi.Index..]; - return sb + text; - } - - private void ParseWeather(string content) - { - - var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b"); - if (time.Success) - { - Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}"; - ScenarioTimeOverride = Time; - } - - var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})"); - if (ovr.Success) - { - ScenarioTimeOverride = $"{ovr.Groups[1].Value.PadLeft(2, '0')}:{ovr.Groups[2].Value}"; - HasWeather = true; - } - else if (!time.Success) - { - - var now = System.DateTime.Now; - ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}"; - } - - var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)"); - if (move.Success && int.TryParse(move.Groups[1].Value, out int day)) - { - Day = day; - HasWeather = true; - } - - var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)"); - if (temp.Success && - double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double t)) - { - Temperature = t; - HasWeather = true; - } - - var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b"); - if (atmo.Success) - { - var nums = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?") - .Select(m => m.Value).ToList(); - if (nums.Count >= 5 && - int.TryParse(nums[3], out int fogStart) && - int.TryParse(nums[4], out int fogEnd)) - { - FogStart = Math.Clamp(fogStart, FogMin, FogMax); - fogEnd = Math.Clamp(fogEnd, FogStart, FogMax); - FogEnd = fogEnd > FogStart - ? Random.Shared.Next(FogStart, fogEnd + 1) - : fogEnd; - HasWeather = true; - } - if (nums.Count >= 6 && - double.TryParse(nums[^1], System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double oc)) - Overcast = oc; - } - } - - private void SnapshotWeather() - { - _origTime = Time; - _origOverride = ScenarioTimeOverride; - _origDay = Day; - _origTemperature = Temperature; - _origFogEnd = FogEnd; - _origOvercast = Overcast; - _origOvercastRandom = OvercastRandom; - } - - public bool WeatherChanged => - Day != _origDay || - ScenarioTimeOverride != _origOverride || - Math.Abs(Temperature - _origTemperature) > 0.001 || - FogEnd != _origFogEnd || - OvercastRandom != _origOvercastRandom || - (!OvercastRandom && Math.Abs(Overcast - _origOvercast) > 0.001); - - public void RestoreWeather() - { - Time = _origTime; - ScenarioTimeOverride = _origOverride; - Day = _origDay; - Temperature = _origTemperature; - FogEnd = _origFogEnd; - Overcast = _origOvercast; - OvercastRandom = _origOvercastRandom; - WeatherDirty = true; - } - - private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 }; - - private double EffectiveOvercast() => - OvercastRandom ? OvercastPresets[Random.Shared.Next(OvercastPresets.Length)] : Overcast; - - private string RewriteWeather(string text) - { - try - { - - string s = text; - - string carried = CarriedConfigLines(s); - - s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " "); - s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); - s = Regex.Replace(s, @"(?is)\batmo\b.*?\bendatmo\b", " "); - s = Regex.Replace(s, @"(?im)^[ \t]*movelight\s+\S+", " "); - s = Regex.Replace(s, @"(?i)scenario\.weather\.temperature\s+\S+", " "); - s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " "); - - var inv = System.Globalization.CultureInfo.InvariantCulture; - - string config = - "config\r\n" + - $"movelight {Day}\r\n" + - $"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" + - $"scenario.time.override {DelphiHourMinute(ScenarioTimeOverride)}\r\n" + - carried + - "endconfig\r\n" + - $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {EffectiveOvercast().ToString(inv)} endatmo\r\n" + - $"time {DelphiHourMinute(Time)} 0 0 endtime\r\n"; - - return config + s; - } - catch - { - return text; - } - } - - private static string DelphiHourMinute(string hhmm) - { - int colon = hhmm.IndexOf(':'); - if (colon <= 0) return hhmm; - return hhmm[..colon].TrimStart('0') is { Length: > 0 } h - ? h + hhmm[colon..] - : "0" + hhmm[colon..]; - } - - private static readonly Regex ManagedConfigLine = new( - @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static string CarriedConfigLines(string text) - { - var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b"); - if (!block.Success) - return ""; - - var sb = new StringBuilder(); - foreach (string raw in block.Groups[1].Value.Split('\n')) - { - string line = raw.TrimEnd('\r').Trim(); - if (line.Length == 0 || ManagedConfigLine.IsMatch(line)) - continue; - sb.Append(line).Append("\r\n"); - } - return sb.ToString(); - } - - private static string MatchAllDirectives(string content, string letter) - { - var matches = Regex.Matches( - content, - @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", - RegexOptions.Multiline - ); - if (matches.Count == 0) - return null; - return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd())); - } - - private static string MatchDirective(string content, string letter) - { - var match = Regex.Match( - content, - @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", - RegexOptions.Multiline - ); - return match.Success ? match.Groups[1].Value.Trim() : null; - } - - public string ImagePath - { - get - { - if (_imagePathResolved) - return _imagePath; - _imagePathResolved = true; - _imagePath = ResolveImagePath(); - return _imagePath; - } - } - - private string ResolveImagePath() - { - if (string.IsNullOrWhiteSpace(ImageName)) - return null; - - string name = ImageName.Replace('\\', '/').Trim(); - string scnDir = System.IO.Path.GetDirectoryName(Path) ?? "."; - string root = System.IO.Path.GetDirectoryName(scnDir) ?? "."; - - var candidates = new List - { - name, - System.IO.Path.Combine(root, name), - System.IO.Path.Combine(scnDir, name), - System.IO.Path.Combine(scnDir, "images", name), - System.IO.Path.Combine(root, "scenery", "images", name), - }; - - foreach (string candidate in candidates) - { - if (File.Exists(candidate)) - return candidate; - } - return null; - } -} diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs index b133614..179dfc8 100644 --- a/StarterNG/Domain/Consist.cs +++ b/StarterNG/Domain/Consist.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using StarterNG.Classes; +using StarterNG.Domain.Sceneries; namespace StarterNG.Domain; diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs new file mode 100644 index 0000000..d340b6c --- /dev/null +++ b/StarterNG/Domain/Sceneries/Scenery.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain; + +namespace StarterNG.Domain.Sceneries; + +/// A file the scenery offers to open alongside it (map, manual, ...). +public sealed class SceneryAttachment +{ + public string FilePath = ""; + public string Label = ""; +} + +/// +/// An include the scenery marks as optional, so the user can decide +/// whether it ends up in the exported scenario. +/// +public sealed class SceneryInclude +{ + public string FilePath = ""; + public string Desc = ""; + + /// 1 = on by default, 2 = only when no companion timetable exists. + public int Kind; + + public bool Selected; +} + +/// +/// A scenario the user can start: its consists, loose vehicles, optional +/// includes and weather, plus the ability to render all of that back into a .scn +/// the simulator can load. +/// +/// +/// The aggregate is pure: it never reads or writes files. Parsing lives in +/// Infrastructure.Sceneries.SceneryParser and loading in +/// SceneryRepository, which is what lets the export rules be tested from +/// a string rather than an installation. +/// +/// is the .scn text with every trainset replaced by a +/// {{index}} placeholder and the optional includes stripped, so +/// only has to substitute rather than re-parse. +/// +public sealed class Scenery +{ + public Scenery(string path, string template) + { + Path = path; + Template = template; + } + + /// Path of the .scn this was parsed from; the aggregate's identity. + public string Path { get; } + + /// The .scn body with trainset placeholders, used for export. + public string Template { get; } + + /// True when a companion .sbt timetable sits next to the .scn. + public bool HasCompanionTimetable { get; set; } + + public string? Group { get; set; } + + public string? Name { get; set; } + + public string? Description { get; set; } + + public string? ImageName { get; set; } + + /// Scenery kept for historical interest; hidden unless asked for. + public bool Archival { get; set; } + + public List Trainsets { get; } = new(); + + public List Attachments { get; } = new(); + + public List Includes { get; } = new(); + + /// Vehicles placed directly as nodes rather than inside a trainset. + public List LooseVehicles { get; } = new(); + + /// + /// Names of every loose vehicle, including those whose node failed to parse + /// and therefore has no . + /// + public List LooseVehicleNames { get; } = new(); + + /// Parse problems worth showing on the scenario's fault list. + public List Faults { get; } = new(); + + public SceneryWeather Weather { get; } = new(); + + public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); + + public string LocalizedDescription => + string.IsNullOrEmpty(Description) + ? "" + : string.Join("\n", Description.Split('\n').Select(SceneryI18n.T)); + + /// + /// Renders the scenario the simulator should load: the original file with the + /// user's consists, weather and optional includes applied. + /// + /// + /// Drops AI trainsets the user marked irrelevant, which shortens loading. + /// + public string BuildExportContent(bool skipDecorTrainsets, IRandomSource random) + { + string result = Template; + + if (Weather.Dirty) + result = SceneryWeatherWriter.Rewrite(result, Weather, random); + + result = InjectIncludes(result); + + for (int i = 0; i < Trainsets.Count; i++) + { + string entry = skipDecorTrainsets && Trainsets[i].Decor + ? string.Empty + : Trainsets[i].ToSceneryEntry(); + result = result.Replace("{{" + i + "}}", entry); + } + + if (LooseVehicles.Count > 0) + result = AppendLooseVehicles(result); + + return result; + } + + private string AppendLooseVehicles(string text) + { + var sb = new StringBuilder(); + foreach (var vehicle in LooseVehicles) + sb.Append(vehicle.ToLooseNode()); + if (sb.Length == 0) + return text; + + return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: true); + } + + private string InjectIncludes(string text) + { + if (Includes.Count == 0) + return text; + + var sb = new StringBuilder(); + foreach (var include in Includes) + { + // Kind 2 is the scenery's own timetable stand-in: it is only wanted + // when the user has not supplied a companion .sbt. + if (include.Kind == 2) + { + if (!HasCompanionTimetable) + sb.Append("include ").Append(include.FilePath).Append(" end\r\n"); + continue; + } + if (include.Selected) + sb.Append("include ").Append(include.FilePath).Append(" end\r\n"); + } + + if (sb.Length == 0) + return text; + + return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: false); + } + + /// + /// Places generated content ahead of FirstInit, which the simulator + /// treats as the end of the definition section. + /// + private static string InsertBeforeFirstInit(string text, string addition, bool fallbackAtEnd) + { + var firstInit = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase); + if (firstInit.Success) + return text[..firstInit.Index] + addition + text[firstInit.Index..]; + + return fallbackAtEnd ? text + addition : addition + text; + } +} diff --git a/StarterNG/Domain/Sceneries/SceneryWeather.cs b/StarterNG/Domain/Sceneries/SceneryWeather.cs new file mode 100644 index 0000000..36f7054 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryWeather.cs @@ -0,0 +1,105 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Sceneries; + +/// +/// The weather and time-of-day a scenery starts with: what the .scn file +/// declared, what the user has changed it to, and whether that differs. +/// +/// +/// Split out of Scenery so the "is this still the authored weather?" +/// rule lives next to the values it compares, instead of being spread over the +/// aggregate and the weather tab. The aggregate owns one of these; the parser +/// fills it and calls once. +/// +public sealed class SceneryWeather +{ + public const int FogMin = 10; + public const int FogMax = 10000; + + /// Overcast values the "random" option picks between. + private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 }; + + private string _authoredTime = "10:30"; + private string _authoredTimeOverride = "10:30"; + private int _authoredDay; + private double _authoredTemperature = 15; + private int _authoredFogEnd = 2000; + private double _authoredOvercast; + private bool _authoredOvercastRandom; + + public string Time { get; set; } = "10:30"; + + public string ScenarioTimeOverride { get; set; } = "10:30"; + + /// Day of the year driving the sun position (movelight). + public int Day { get; set; } + + public double Temperature { get; set; } = 15; + + public int FogStart { get; set; } = 10; + + public int FogEnd { get; set; } = 2000; + + public double Overcast { get; set; } + + /// When set, each export draws a fresh overcast from the presets. + public bool OvercastRandom { get; set; } + + /// True when the .scn actually declared weather, rather than us defaulting it. + public bool IsAuthored { get; set; } + + /// Set once the user has touched the weather, so export rewrites the config block. + public bool Dirty { get; set; } + + /// Takes the current values as the baseline "what the file said". + public void AcceptAsAuthored() + { + _authoredTime = Time; + _authoredTimeOverride = ScenarioTimeOverride; + _authoredDay = Day; + _authoredTemperature = Temperature; + _authoredFogEnd = FogEnd; + _authoredOvercast = Overcast; + _authoredOvercastRandom = OvercastRandom; + } + + /// True when the user's weather differs from what the .scn declared. + public bool Changed => + Day != _authoredDay || + ScenarioTimeOverride != _authoredTimeOverride || + Math.Abs(Temperature - _authoredTemperature) > 0.001 || + FogEnd != _authoredFogEnd || + OvercastRandom != _authoredOvercastRandom || + (!OvercastRandom && Math.Abs(Overcast - _authoredOvercast) > 0.001); + + /// + /// Puts back the authored weather. Stays : the export must + /// still rewrite the config block, because the file on disk may already carry + /// an edit from an earlier run. + /// + public void Restore() + { + Time = _authoredTime; + ScenarioTimeOverride = _authoredTimeOverride; + Day = _authoredDay; + Temperature = _authoredTemperature; + FogEnd = _authoredFogEnd; + Overcast = _authoredOvercast; + OvercastRandom = _authoredOvercastRandom; + Dirty = true; + } + + /// Clamps a parsed fog range and picks the distance to run with. + public void SetFogRange(int start, int end, IRandomSource random) + { + FogStart = Math.Clamp(start, FogMin, FogMax); + end = Math.Clamp(end, FogStart, FogMax); + FogEnd = end > FogStart ? random.Next(FogStart, end + 1) : end; + } + + /// The overcast to export, drawing a preset when randomisation is on. + public double EffectiveOvercast(IRandomSource random) => + OvercastRandom ? OvercastPresets[random.Next(0, OvercastPresets.Length)] : Overcast; +} diff --git a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs new file mode 100644 index 0000000..2b9d069 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs @@ -0,0 +1,93 @@ +using System; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Sceneries; + +/// +/// Renders a back into the .scn dialect: strips the +/// blocks the starter owns and prepends freshly written ones. +/// +/// +/// A pure text transformation, kept out of the aggregate because it is entirely +/// about the file format rather than about the scenario. +/// +public static class SceneryWeatherWriter +{ + /// Config lines the starter writes itself and must not carry over. + private static readonly Regex ManagedConfigLine = new( + @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static string Rewrite(string text, SceneryWeather weather, IRandomSource random) + { + try + { + string carried = CarriedConfigLines(text); + + string stripped = text; + stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " "); + stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); + stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " "); + stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " "); + stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " "); + stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " "); + + var inv = CultureInfo.InvariantCulture; + string config = + "config\r\n" + + $"movelight {weather.Day}\r\n" + + $"scenario.weather.temperature {weather.Temperature.ToString(inv)}\r\n" + + $"scenario.time.override {DelphiHourMinute(weather.ScenarioTimeOverride)}\r\n" + + carried + + "endconfig\r\n" + + $"atmo 0 0 0 {weather.FogEnd} {weather.FogEnd} 0 0 0 " + + $"{weather.EffectiveOvercast(random).ToString(inv)} endatmo\r\n" + + $"time {DelphiHourMinute(weather.Time)} 0 0 endtime\r\n"; + + return config + stripped; + } + catch + { + // A scenery we cannot rewrite is still worth starting unmodified. + return text; + } + } + + /// + /// The simulator's parser expects an unpadded hour ("7:30", not "07:30"). + /// + private static string DelphiHourMinute(string hhmm) + { + int colon = hhmm.IndexOf(':'); + if (colon <= 0) + return hhmm; + + return hhmm[..colon].TrimStart('0') is { Length: > 0 } hour + ? hour + hhmm[colon..] + : "0" + hhmm[colon..]; + } + + /// + /// Config entries the scenery author wrote that are none of the starter's + /// business, preserved verbatim across a rewrite. + /// + private static string CarriedConfigLines(string text) + { + var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b"); + if (!block.Success) + return ""; + + var sb = new StringBuilder(); + foreach (string raw in block.Groups[1].Value.Split('\n')) + { + string line = raw.TrimEnd('\r').Trim(); + if (line.Length == 0 || ManagedConfigLine.IsMatch(line)) + continue; + sb.Append(line).Append("\r\n"); + } + return sb.ToString(); + } +} diff --git a/StarterNG/Domain/SceneryI18n.cs b/StarterNG/Domain/SceneryI18n.cs index dd6e001..2b570fd 100644 --- a/StarterNG/Domain/SceneryI18n.cs +++ b/StarterNG/Domain/SceneryI18n.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; using StarterNG.Classes; +using StarterNG.Domain.Sceneries; namespace StarterNG.Domain; diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index dfdd33f..e4e0aa4 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text; using StarterNG.Classes; +using StarterNG.Domain.Sceneries; namespace StarterNG.Domain; diff --git a/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs new file mode 100644 index 0000000..debbfb8 --- /dev/null +++ b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs @@ -0,0 +1,12 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Adapters; + +/// backed by . +public sealed class SystemRandomSource : IRandomSource +{ + public int Next(int minInclusive, int maxExclusive) => Random.Shared.Next(minInclusive, maxExclusive); + + public double NextDouble() => Random.Shared.NextDouble(); +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs new file mode 100644 index 0000000..42b95a5 --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +/// +/// Finds the preview image a scenery names, which authors place in any of +/// several conventional spots relative to the .scn. +/// +/// +/// Was a lazy property on the aggregate; moved out because it is a lookup on +/// disk. Results are cached per scenery path, which is what the lazy field +/// bought us before. +/// +public sealed class SceneryImageLocator +{ + private readonly IFileSystem _files; + private readonly Dictionary _cache = new(); + + public SceneryImageLocator(IFileSystem files) + { + _files = files; + } + + /// The image file for this scenery, or null when there is none. + public string? Resolve(Scenery scenery) + { + lock (_cache) + { + if (_cache.TryGetValue(scenery.Path, out string? cached)) + return cached; + + string? resolved = Search(scenery); + _cache[scenery.Path] = resolved; + return resolved; + } + } + + private string? Search(Scenery scenery) + { + if (string.IsNullOrWhiteSpace(scenery.ImageName)) + return null; + + string name = scenery.ImageName.Replace('\\', '/').Trim(); + string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? "."; + string root = Path.GetDirectoryName(sceneryDir) ?? "."; + + string[] candidates = + { + name, + Path.Combine(root, name), + Path.Combine(sceneryDir, name), + Path.Combine(sceneryDir, "images", name), + Path.Combine(root, "scenery", "images", name) + }; + + foreach (string candidate in candidates) + if (_files.FileExists(candidate)) + return candidate; + + return null; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs new file mode 100644 index 0000000..7897255 --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +/// +/// Turns the text of a .scn file into a . +/// +/// +/// All knowledge of the .scn dialect lives here, so the aggregate stays free of +/// regular expressions and the parser can be exercised on a string. Anything +/// malformed is recorded as a fault rather than thrown: a scenery with one bad +/// consist is still worth listing. +/// +public sealed class SceneryParser +{ + private readonly IClock _clock; + private readonly IRandomSource _random; + + public SceneryParser(IClock clock, IRandomSource random) + { + _clock = clock; + _random = random; + } + + public Scenery Parse(string path, string content) + { + // Directives, attachments and weather are read from the file as written; + // the placeholder rewriting below only shapes the export template. + string original = content; + + var faults = new List(); + var attachments = ParseAttachments(original); + + var includes = new List(); + content = StripOptionalIncludes(content, includes); + + // Trainsets are lifted out first so the placeholders survive the loose + // vehicle sweep below, which would otherwise match nodes inside them. + var trainsetEntries = new List(); + content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b", + match => + { + trainsetEntries.Add(match.Value); + return $"{{{{{trainsetEntries.Count - 1}}}}}"; + }, + RegexOptions.IgnoreCase); + + var looseVehicles = new List(); + var looseNames = new List(); + content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults); + + var scenery = new Scenery(path, content) + { + Group = MatchDirective(original, "l"), + Name = MatchDirective(original, "n"), + Description = MatchAllDirectives(original, "d"), + ImageName = MatchDirective(original, "i"), + Archival = MatchDirective(original, "a") != null + }; + + scenery.Attachments.AddRange(attachments); + scenery.Includes.AddRange(includes); + scenery.LooseVehicles.AddRange(looseVehicles); + scenery.LooseVehicleNames.AddRange(looseNames); + scenery.Faults.AddRange(faults); + + ParseWeather(original, scenery.Weather); + scenery.Weather.AcceptAsAuthored(); + + foreach (string entry in trainsetEntries) + { + var trainset = new Trainset(entry); + scenery.Trainsets.Add(trainset); + if (!trainset.Parsed) + scenery.Faults.Add($"# trainset parse fault: {trainset.Name} ({trainset.Track})"); + } + + return scenery; + } + + private static List ParseAttachments(string content) + { + var attachments = new List(); + + foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline)) + { + string rest = m.Groups[1].Value.Trim(); + if (rest.Length == 0) + continue; + + string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + // A leading one- or two-character token is a legacy ordering hint. + if (parts.Length >= 3 && parts[0].Length <= 2) + attachments.Add(new SceneryAttachment + { + FilePath = parts[1], + Label = string.Join(" ", parts.Skip(2)) + }); + else if (parts.Length >= 2) + attachments.Add(new SceneryAttachment + { + FilePath = parts[0], + Label = string.Join(" ", parts.Skip(1)) + }); + else + attachments.Add(new SceneryAttachment + { + FilePath = parts[0], + Label = System.IO.Path.GetFileName(parts[0]) + }); + } + + return attachments; + } + + /// + /// Removes the includes the scenery marked $optional from the text and + /// records them, so the user can pick which ones the export puts back. + /// + private static string StripOptionalIncludes(string content, List includes) + { + if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0) + return content; + + return Regex.Replace( + content, + @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b", + m => + { + string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " "); + string raw = m.Groups[2].Value.Trim().TrimStart(',', ' '); + string[] parameters = raw.Split(new[] { ',', '|', ';' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + int kind = 1; + string description = includePath; + if (parameters.Length >= 1 && int.TryParse(parameters[0], out int parsedKind)) + kind = parsedKind; + if (parameters.Length >= 2) + description = parameters[1]; + + includes.Add(new SceneryInclude + { + FilePath = includePath, + Desc = description, + Kind = kind, + Selected = kind == 1 + }); + return " "; + }, + RegexOptions.IgnoreCase); + } + + /// + /// Lifts node ... dynamic ... enddynamic entries that sit outside any + /// trainset, so the depot can show and reorder them like a consist. + /// + private static string ExtractLooseVehicles(string content, List vehicles, List names, + List faults) + { + var inv = CultureInfo.InvariantCulture; + + return Regex.Replace( + content, + @"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b", + m => + { + string name = m.Groups[3].Value; + + // A placeholder means the node belongs to a trainset already lifted out. + if (name.StartsWith("{{", StringComparison.Ordinal)) + return m.Value; + + try + { + var vehicle = new Dynamic + { + RangeMax = float.Parse(m.Groups[1].Value, inv), + RangeMin = float.Parse(m.Groups[2].Value, inv), + Name = name, + DataFolder = m.Groups[4].Value, + SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value), + MmdFile = m.Groups[6].Value, + PathName = m.Groups[7].Value, + Offset = float.Parse(m.Groups[8].Value, inv), + DriverType = m.Groups[9].Value.ToLowerInvariant() switch + { + "headdriver" => eDriverType.Headdriver, + "reardriver" => eDriverType.Reardriver, + "passenger" => eDriverType.Passenger, + _ => eDriverType.Nobody + }, + HasVelocity = true, + Velocity = float.Parse(m.Groups[10].Value, inv) + }; + + var trailing = m.Groups[11].Value + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + vehicle.ReadTrailing(trailing); + + vehicles.Add(vehicle); + names.Add(vehicle.Name); + return "\n"; + } + catch + { + // Leave the node in the template so the export stays faithful. + faults.Add($"# loose dynamic parse fault: {name}"); + names.Add(name); + return m.Value; + } + }); + } + + private void ParseWeather(string content, SceneryWeather weather) + { + var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b"); + if (time.Success) + { + weather.Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}"; + weather.ScenarioTimeOverride = weather.Time; + } + + var over = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})"); + if (over.Success) + { + weather.ScenarioTimeOverride = $"{over.Groups[1].Value.PadLeft(2, '0')}:{over.Groups[2].Value}"; + weather.IsAuthored = true; + } + else if (!time.Success) + { + // Nothing authored: start the scenario at the player's wall clock. + var now = _clock.Now; + weather.ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}"; + } + + var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)"); + if (move.Success && int.TryParse(move.Groups[1].Value, out int day)) + { + weather.Day = day; + weather.IsAuthored = true; + } + + var temperature = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)"); + if (temperature.Success && + double.TryParse(temperature.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, + out double celsius)) + { + weather.Temperature = celsius; + weather.IsAuthored = true; + } + + var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b"); + if (!atmo.Success) + return; + + var numbers = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?") + .Select(m => m.Value) + .ToList(); + + if (numbers.Count >= 5 && + int.TryParse(numbers[3], out int fogStart) && + int.TryParse(numbers[4], out int fogEnd)) + { + weather.SetFogRange(fogStart, fogEnd, _random); + weather.IsAuthored = true; + } + + if (numbers.Count >= 6 && + double.TryParse(numbers[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double overcast)) + weather.Overcast = overcast; + } + + private static string? MatchAllDirectives(string content, string letter) + { + var matches = Regex.Matches(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + return matches.Count == 0 + ? null + : string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd())); + } + + private static string? MatchDirective(string content, string letter) + { + var match = Regex.Match(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline); + return match.Success ? match.Groups[1].Value.Trim() : null; + } +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs new file mode 100644 index 0000000..afcb536 --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +/// +/// Loads scenarios from the installation's scenery folder. +/// +/// +/// Sceneries are code page 1250 text, and a large installation holds hundreds of +/// them, so the sweep is parallel; each file that throws is logged and dropped +/// rather than taking the whole load down. +/// +public sealed class SceneryRepository : ISceneryRepository +{ + /// Files starting with '$' are authoring scratch, not scenarios. + private const char ExcludedPrefix = '$'; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly SceneryParser _parser; + private readonly Encoding _encoding; + + public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser) + { + _files = files; + _paths = paths; + _log = log; + _parser = parser; + _encoding = CodePage1250(); + } + + public IReadOnlyList LoadAll(IProgress? progress = null) + { + var files = SceneryFiles().ToList(); + var loaded = new ConcurrentBag(); + int total = Math.Max(1, files.Count); + int done = 0; + + Parallel.ForEach(files, + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + file => + { + var scenery = Load(file); + if (scenery is not null) + loaded.Add(scenery); + + progress?.Report(new SceneryLoadProgress(Interlocked.Increment(ref done), total, + Path.GetFileName(file))); + }); + + return loaded.ToList(); + } + + public Scenery? Load(string path) + { + try + { + if (!_files.FileExists(path)) + return null; + + var scenery = _parser.Parse(path, _files.ReadAllText(path, _encoding)); + scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt")); + return scenery; + } + catch (Exception ex) + { + _log.Log($"scenery/{Path.GetFileName(path)}", ex); + return null; + } + } + + private IEnumerable SceneryFiles() => + _files.GetFiles(_paths.Scenery, "*.scn") + .Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix); + + /// + /// The .scn dialect predates Unicode. The provider is registered by the entry + /// point; fall back to Latin-1 if that ever fails so loading still works. + /// + private static Encoding CodePage1250() + { + try + { + return Encoding.GetEncoding(1250); + } + catch (Exception) + { + return Encoding.Latin1; + } + } +} diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 7dbe6d8..6e80542 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -17,6 +17,8 @@ using StarterNG.Infrastructure; using StarterNG.Services; using StarterNG.Views; using SettingsModel = StarterNG.Classes.Settings; +using StarterNG.Application; +using StarterNG.Domain.Sceneries; namespace StarterNG; @@ -443,14 +445,14 @@ public partial class MainWindow : Window LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); - scenery.WeatherDirty = true; + scenery.Weather.Dirty = true; string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery"; string exportName = "$" + Path.GetFileName(scenery.Path); string exportPath = Path.Combine(dir, exportName); try { - File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains), Encoding.GetEncoding(1250)); + File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250)); } catch (Exception ex) { diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index d527ec0..0146036 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -18,6 +18,7 @@ using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; using StarterNG.Domain; +using StarterNG.Domain.Sceneries; namespace StarterNG.Views; diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index 08ad464..9c4d123 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -14,6 +14,7 @@ using Avalonia.Threading; using StarterNG.Classes; using StarterNG.Domain; +using StarterNG.Domain.Sceneries; namespace StarterNG.Views; diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 46d8299..2a7536c 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -17,6 +17,8 @@ using Avalonia.Markup.Xaml; using Avalonia.Media.Imaging; using StarterNG.Classes; using StarterNG.Domain; +using StarterNG.Application; +using StarterNG.Domain.Sceneries; namespace StarterNG.Views; @@ -310,7 +312,7 @@ public partial class Scenarios : UserControl SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode); sceneryDescription.Text = scenery.LocalizedDescription; - string? imagePath = scenery.ImagePath; + string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery); if (imagePath is not null) { try @@ -551,16 +553,17 @@ public partial class Scenarios : UserControl try { weatherForm.IsEnabled = true; - weatherTime.SelectedTime = ParseTime(scenery.ScenarioTimeOverride); - weatherDate.SelectedDate = DateOfDayOfYear(scenery.Day); - weatherTemp.Value = scenery.Temperature; - weatherFog.Value = SliderFromFog(scenery.FogEnd); - SelectByTag(weatherSeason, scenery.Day.ToString(), excludeFromNearest: "0"); - SelectByTag(weatherOvercast, scenery.OvercastRandom + var weather = scenery.Weather; + weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride); + weatherDate.SelectedDate = DateOfDayOfYear(weather.Day); + weatherTemp.Value = weather.Temperature; + weatherFog.Value = SliderFromFog(weather.FogEnd); + SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0"); + SelectByTag(weatherOvercast, weather.OvercastRandom ? RandomTag - : scenery.Overcast.ToString(CultureInfo.InvariantCulture)); + : weather.Overcast.ToString(CultureInfo.InvariantCulture)); - ApplyTodayPreview(scenery.Day == 0); + ApplyTodayPreview(weather.Day == 0); UpdateWeatherLabels(); } finally @@ -574,22 +577,22 @@ public partial class Scenarios : UserControl if (_loadingWeather || _currentScenery is null) return; - var scn = _currentScenery; + var weather = _currentScenery.Weather; if (weatherTime.SelectedTime is { } t) - scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}"; + weather.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}"; - scn.Day = _todaySeason ? 0 : SelectedDayOfYear(); - scn.Temperature = weatherTemp.Value; - scn.FogEnd = FogFromSlider(weatherFog.Value); + weather.Day = _todaySeason ? 0 : SelectedDayOfYear(); + weather.Temperature = weatherTemp.Value; + weather.FogEnd = FogFromSlider(weatherFog.Value); if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov) { - scn.OvercastRandom = ov == RandomTag; - if (!scn.OvercastRandom && + weather.OvercastRandom = ov == RandomTag; + if (!weather.OvercastRandom && double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc)) - scn.Overcast = oc; + weather.Overcast = oc; } - scn.WeatherDirty = true; + weather.Dirty = true; UpdateWeatherLabels(); } @@ -599,23 +602,23 @@ public partial class Scenarios : UserControl weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value)); weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear()); - weatherRestore.IsEnabled = _currentScenery?.WeatherChanged ?? false; + weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false; } private static int FogFromSlider(double position) { - double metres = Scenery.FogMin * - System.Math.Pow((double)Scenery.FogMax / Scenery.FogMin, position / 1000.0); + double metres = SceneryWeather.FogMin * + System.Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, position / 1000.0); int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100; return System.Math.Clamp((int)(System.Math.Round(metres / step) * step), - Scenery.FogMin, Scenery.FogMax); + SceneryWeather.FogMin, SceneryWeather.FogMax); } private static double SliderFromFog(int metres) { - double clamped = System.Math.Clamp(metres, Scenery.FogMin, Scenery.FogMax); - return 1000.0 * System.Math.Log(clamped / Scenery.FogMin) / - System.Math.Log((double)Scenery.FogMax / Scenery.FogMin); + double clamped = System.Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax); + return 1000.0 * System.Math.Log(clamped / SceneryWeather.FogMin) / + System.Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin); } private static string FormatFog(int metres) => @@ -688,7 +691,7 @@ public partial class Scenarios : UserControl private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e) { if (_currentScenery is null) return; - _currentScenery.RestoreWeather(); + _currentScenery.Weather.Restore(); ShowWeather(_currentScenery); }