From 69aa075bfaeee1c3a569cfa3c9fad13c3ccd61c2 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:06:15 +0100 Subject: [PATCH] Keep scenery files intact on save and follow them on disk Saves keep comments and every byte they do not change. Faults in a scenery's text are found and described. Files that change while the starter runs are picked up without a manual reload. --- StarterNG.Tests/Fakes/TestScenery.cs | 16 + StarterNG.Tests/GameLibraryTests.cs | 41 ++ StarterNG.Tests/SceneryExportTests.cs | 74 ++- StarterNG.Tests/SceneryFaultTextTests.cs | 77 +++ StarterNG.Tests/SceneryFileWatchTests.cs | 376 ++++++++++++ StarterNG.Tests/SceneryParserTests.cs | 57 +- StarterNG.Tests/ScenerySyntaxTests.cs | 382 ++++++++++++ StarterNG.Tests/SceneryTrainsetSaveTests.cs | 578 ++++++++++++++++++ StarterNG.Tests/SceneryWeatherTests.cs | 175 +++++- .../Abstractions/ISceneryRepository.cs | 3 + StarterNG/Application/GameLibrary.cs | 15 +- StarterNG/Application/SaveSceneryTrainset.cs | 87 +++ StarterNG/Application/SceneryFileWatch.cs | 204 +++++++ StarterNG/Classes/Trainset.cs | 181 +++--- StarterNG/Domain/Sceneries/FileStamp.cs | 23 + StarterNG/Domain/Sceneries/Scenery.cs | 14 +- StarterNG/Domain/Sceneries/SceneryCode.cs | 85 +++ StarterNG/Domain/Sceneries/SceneryFault.cs | 104 ++++ .../Domain/Sceneries/SceneryFaultText.cs | 37 ++ StarterNG/Domain/Sceneries/SceneryIncludes.cs | 78 +++ StarterNG/Domain/Sceneries/SceneryLines.cs | 66 ++ StarterNG/Domain/Sceneries/ScenerySyntax.cs | 528 ++++++++++++++++ .../Domain/Sceneries/SceneryTrainsetText.cs | 183 ++++++ StarterNG/Domain/Sceneries/SceneryWeather.cs | 17 + .../Domain/Sceneries/SceneryWeatherWriter.cs | 84 ++- StarterNG/Domain/SimulatorNames.cs | 18 + .../Infrastructure/Sceneries/SceneryParser.cs | 188 +++--- .../Sceneries/SceneryRepository.cs | 94 ++- .../Sceneries/TimetableLocator.cs | 24 + StarterNG/Views/Scenarios.axaml.cs | 2 +- 30 files changed, 3596 insertions(+), 215 deletions(-) create mode 100644 StarterNG.Tests/Fakes/TestScenery.cs create mode 100644 StarterNG.Tests/SceneryFaultTextTests.cs create mode 100644 StarterNG.Tests/SceneryFileWatchTests.cs create mode 100644 StarterNG.Tests/ScenerySyntaxTests.cs create mode 100644 StarterNG.Tests/SceneryTrainsetSaveTests.cs create mode 100644 StarterNG/Application/SaveSceneryTrainset.cs create mode 100644 StarterNG/Application/SceneryFileWatch.cs create mode 100644 StarterNG/Domain/Sceneries/FileStamp.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryCode.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryFault.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryFaultText.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryIncludes.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryLines.cs create mode 100644 StarterNG/Domain/Sceneries/ScenerySyntax.cs create mode 100644 StarterNG/Domain/Sceneries/SceneryTrainsetText.cs create mode 100644 StarterNG/Domain/SimulatorNames.cs diff --git a/StarterNG.Tests/Fakes/TestScenery.cs b/StarterNG.Tests/Fakes/TestScenery.cs new file mode 100644 index 0000000..79a2687 --- /dev/null +++ b/StarterNG.Tests/Fakes/TestScenery.cs @@ -0,0 +1,16 @@ +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; + +namespace StarterNG.Tests.Fakes; + +/// The scenery reader over a fake clock and dice, for tests of what it makes of a text. +public static class TestScenery +{ + /// The hour a scenery that names none starts at. + public static SceneryParser Parser(DateTime? now = null) => + new(new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom()); + + public static SceneryRepository Repository(IFileSystem files) => + new(files, new GamePaths("game"), TestLog.Over(files), Parser()); +} diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs index 1efe8a5..d2a2dfd 100644 --- a/StarterNG.Tests/GameLibraryTests.cs +++ b/StarterNG.Tests/GameLibraryTests.cs @@ -72,4 +72,45 @@ public class GameLibraryTests Assert.Contains("textures.txt", installation.Log.Text); } + + [Fact] + public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09") + .WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1") + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .DenyListing(TestInstallation.At("dynamic", "aaa")); + var installation = new TestInstallation(files); + + installation.Library.Load(); + + Assert.True(installation.Library.Loaded); + Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile); + Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name); + Assert.Contains("aaa", installation.Log.Text); + Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass); + } + + [Fact] + public void The_whole_rolling_stock_failing_still_leaves_the_sceneries() + { + var installation = Complete(); + var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures, + installation.Physics, installation.Log); + + library.Load(); + + Assert.True(library.Loaded); + Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name); + Assert.Contains("no disk today", installation.Log.Text); + } + + private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository + { + public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) => + throw new IOException("no disk today"); + } } diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs index 4fdbb1c..2bc93e9 100644 --- a/StarterNG.Tests/SceneryExportTests.cs +++ b/StarterNG.Tests/SceneryExportTests.cs @@ -1,13 +1,9 @@ -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() { @@ -18,7 +14,7 @@ public class SceneryExportTests endtrainset FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); @@ -27,6 +23,45 @@ public class SceneryExportTests Assert.Contains("endtrainset", exported); } + [Fact] + public void A_consist_emptied_stays_out_of_the_export() + { + 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 = TestScenery.Parser().Parse("scenery/x.scn", scn); + + scenery.Trainsets[0].RemoveVehicles(); + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("trainset pociag", exported); + Assert.DoesNotContain("ep07-001", exported); + Assert.Contains("FirstInit", exported); + } + + [Fact] + public void An_entry_that_could_not_be_read_goes_when_emptied_too() + { + 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 = TestScenery.Parser().Parse("scenery/x.scn", scn); + scenery.Trainsets[0].Parsed = false; + + scenery.Trainsets[0].RemoveVehicles(); + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + + Assert.DoesNotContain("ep07-001", exported); + } + [Fact] public void Selected_optional_includes_are_written_back_before_FirstInit() { @@ -36,7 +71,7 @@ public class SceneryExportTests endoptional FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); @@ -54,7 +89,7 @@ public class SceneryExportTests endoptional FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); scenery.Includes[0].Selected = false; string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); @@ -71,7 +106,7 @@ public class SceneryExportTests endoptional FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); scenery.HasCompanionTimetable = false; Assert.Contains("include rozklad.inc", @@ -95,7 +130,7 @@ public class SceneryExportTests time 8:30 0 0 endtime FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); scenery.Weather.Day = 200; scenery.Weather.Temperature = 25; @@ -120,7 +155,7 @@ public class SceneryExportTests endtrainset FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); Assert.True(scenery.Trainsets[0].Decor); Assert.DoesNotContain("trainset ai_1", @@ -136,11 +171,28 @@ public class SceneryExportTests 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 scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); Assert.Contains("wagon_1", exported); Assert.Contains("enddynamic", exported); } + + [Theory] + [InlineData(0, "3 0 enddynamic")] + [InlineData(3, "3 3 pantstate enddynamic")] + public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail) + { + // After a 0 the simulator would read the type as a destination and take + // the next vehicle with it. + var car = new StarterNG.Classes.Dynamic + { + Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m", + LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count + }; + car.Coupling.Flags = 3; + + Assert.EndsWith(tail + "\n", car.ToTrainsetNode()); + } } diff --git a/StarterNG.Tests/SceneryFaultTextTests.cs b/StarterNG.Tests/SceneryFaultTextTests.cs new file mode 100644 index 0000000..9a94b0a --- /dev/null +++ b/StarterNG.Tests/SceneryFaultTextTests.cs @@ -0,0 +1,77 @@ +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Tests; + +public class SceneryFaultTextTests +{ + private static string Wording(string key) => key switch + { + "SyntaxLost" => "lost: {0}", + "SyntaxUnmade" => "unmade: {0}", + "SyntaxMore" => "and {0} more", + _ => key + " {0} {1}" + }; + + private static string Format(int limit, params SceneryFault[] faults) => + SceneryFaultText.Format(faults, limit, Wording); + + [Fact] + public void No_faults_read_as_nothing() => + Assert.Equal("", Format(10)); + + [Fact] + public void The_place_and_the_fault_stand_in_two_columns_as_wide_as_the_longest_place() + { + string text = Format(10, + new SceneryFault(SceneryFaultKind.VehicleIncomplete, "a.scn", 3, "v1", "x"), + new SceneryFault(SceneryFaultKind.NotANumber, "long.inc", 120, "v2", "y")); + + Assert.Equal( + "a.scn:3 SyntaxVehicleIncomplete v1 x\n" + + "long.inc:120 SyntaxNotANumber v2 y", + text); + } + + [Fact] + public void What_a_fault_has_no_subject_or_token_for_is_filled_in() + { + string text = Format(10, new SceneryFault(SceneryFaultKind.StrayEndTrainset, "a.scn", 3)); + + Assert.Equal("a.scn:3 SyntaxStrayEndTrainset ?", text); + } + + [Fact] + public void What_a_fault_costs_stands_under_it_in_the_second_column() + { + string text = Format(10, + new SceneryFault(SceneryFaultKind.VehicleNotClosed, "a.scn", 3, "v1", "x") { Lost = "v2, v3", Unmade = "b" }); + + Assert.Equal( + "a.scn:3 SyntaxVehicleNotClosed v1 x\n" + + " lost: v2, v3\n" + + " unmade: b", + text); + } + + [Fact] + public void Past_the_limit_the_faults_are_only_counted_and_do_not_widen_the_column() + { + string text = Format(1, + new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"), + new SceneryFault(SceneryFaultKind.NotANumber, "a-longer-name.scn", 4, "v2", "y"), + new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 5, "v3", "z")); + + Assert.Equal( + "a.scn:3 SyntaxNotANumber v1 x\n" + + "and 2 more", + text); + } + + [Fact] + public void Exactly_as_many_as_the_limit_are_not_counted_as_more() + { + string text = Format(1, new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x")); + + Assert.DoesNotContain("more", text); + } +} diff --git a/StarterNG.Tests/SceneryFileWatchTests.cs b/StarterNG.Tests/SceneryFileWatchTests.cs new file mode 100644 index 0000000..d33bd3f --- /dev/null +++ b/StarterNG.Tests/SceneryFileWatchTests.cs @@ -0,0 +1,376 @@ +using StarterNG.Application; +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryFileWatchTests +{ + private static readonly string Scn = TestInstallation.At("scenery", "kato.scn"); + private static readonly string Other = TestInstallation.At("scenery", "lk4.scn"); + private static readonly string Inc = TestInstallation.At("scenery", "auta.inc"); + + private const string Text = + "//$n Katowice\n" + + "FirstInit\n" + + "trainset a tor 0 0\n" + + "node -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\n" + + "endtrainset\n" + + "trainset b tor 0 0\n" + + "node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" + + "endtrainset\n"; + + private static (TestInstallation Installation, SceneryFileWatch Watch, AppState State) Rig(InMemoryFileSystem files) + { + var installation = new TestInstallation(files); + installation.Library.ReloadSceneries(); + var state = new AppState(); + return (installation, new SceneryFileWatch(files, installation.Sceneries, installation.Library, state), state); + } + + private static InMemoryFileSystem Folder() => new InMemoryFileSystem().WithFile(Scn, Text); + + [Fact] + public void Files_as_they_were_read_are_no_change() + { + var (installation, watch, _) = Rig(Folder()); + + Assert.Empty(watch.Look(installation.Library.Sceneries)); + } + + [Fact] + public void A_file_changed_outside_is_read_anew() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + + var change = Assert.Single(watch.Look(installation.Library.Sceneries)); + Assert.Same(current, change.Current); + Assert.Equal("Katowice Główne", change.Fresh!.Name); + } + + [Fact] + public void An_include_that_changed_or_came_makes_the_scenery_read_anew() + { + var files = new InMemoryFileSystem().WithFile(Scn, Text + "include auta.inc end\n"); + var (installation, watch, _) = Rig(files); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + + files.WriteAllText(Inc, "// auta\n"); + Assert.Single(watch.Look(installation.Library.Sceneries)); + + installation.Library.ReloadSceneries(); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + + files.WriteAllText(Inc, "// inne auta\n"); + Assert.Single(watch.Look(installation.Library.Sceneries)); + } + + [Fact] + public void A_new_file_in_the_folder_comes_as_an_added_scenery() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + + files.WriteAllText(Other, "//$n Lublin\n"); + + var change = Assert.Single(watch.Look(installation.Library.Sceneries)); + Assert.Null(change.Current); + Assert.Equal("Lublin", change.Fresh!.Name); + } + + [Fact] + public void A_file_is_gone_only_if_still_away_on_the_next_look() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + + files.DeleteFile(Scn); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + + var change = Assert.Single(watch.Look(installation.Library.Sceneries)); + Assert.Same(current, change.Current); + Assert.Null(change.Fresh); + } + + [Fact] + public void A_file_replaced_between_two_looks_is_not_gone() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + + files.DeleteFile(Scn); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + + files.WriteAllText(Scn, Text); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + } + + [Fact] + public void A_file_that_will_not_load_is_tried_and_logged_once_until_it_can_be() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + files.WriteAllText(Other, "//$n Lublin\n"); + files.FailReading(Other); + + Assert.Empty(watch.Look(installation.Library.Sceneries)); + string once = installation.Log.Text; + Assert.Contains("lk4.scn", once); + + Assert.Empty(watch.Look(installation.Library.Sceneries)); + Assert.Equal(once, installation.Log.Text); + + files.AllowReading(Other); + Assert.Equal("Lublin", Assert.Single(watch.Look(installation.Library.Sceneries)).Fresh!.Name); + } + + [Fact] + public void Its_own_save_is_no_change_from_outside() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var scenery = installation.Library.Sceneries.Single(); + var trainset = scenery.Trainsets[0]; + trainset.ReadyToGo = true; + + var log = new FileDiagnosticsLog(files, installation.Clock, installation.Paths); + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, trainset).Outcome); + + Assert.False(watch.IsStale(scenery)); + Assert.Empty(watch.Look(installation.Library.Sceneries)); + } + + [Fact] + public void A_consist_whose_text_stands_goes_over_with_its_edits() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + var edited = current.Trainsets[1]; + edited.ReadyToGo = true; + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!; + + Assert.Empty(SceneryFileWatch.CarryOver(current, fresh)); + Assert.Same(edited, fresh.Trainsets[1]); + Assert.True(SceneryTrainsetText.IsModified(fresh.Trainsets[1])); + } + + [Fact] + public void An_edited_consist_whose_text_changed_too_is_the_conflict() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + var edited = current.Trainsets[1]; + edited.ReadyToGo = true; + + files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 ")); + var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!; + + Assert.Same(edited, Assert.Single(SceneryFileWatch.CarryOver(current, fresh))); + Assert.NotSame(edited, fresh.Trainsets[1]); + } + + [Fact] + public void A_consist_changed_but_not_edited_just_gives_way() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + + files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 ")); + var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!; + + Assert.Empty(SceneryFileWatch.CarryOver(current, fresh)); + Assert.Equal("v22", fresh.Trainsets[1].Vehicles.Single().Name); + } + + [Fact] + public void The_weather_chosen_by_hand_stays_and_the_authored_one_follows_the_file() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + current.Weather.Day = 123; + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!; + SceneryFileWatch.CarryOver(current, fresh); + + Assert.Equal(123, fresh.Weather.Day); + } + + [Fact] + public void What_is_picked_follows_into_the_scenery_read_anew() + { + var files = Folder(); + var (installation, watch, state) = Rig(files); + var current = installation.Library.Sceneries.Single(); + var picked = current.Trainsets[1]; + state.CurrentScenery = current; + state.CurrentTrainset = picked; + state.StartingVehicleName = "v2"; + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var change = watch.Look(installation.Library.Sceneries).Single(); + SceneryFileWatch.CarryOver(change.Current!, change.Fresh!); + watch.Apply(change); + + Assert.Same(change.Fresh, installation.Library.Sceneries.Single()); + Assert.Same(change.Fresh, state.CurrentScenery); + Assert.Same(picked, state.CurrentTrainset); + Assert.Equal("v2", state.StartingVehicleName); + } + + [Fact] + public void A_picked_consist_changed_in_the_file_is_followed_by_its_name() + { + var files = Folder(); + var (installation, watch, state) = Rig(files); + var current = installation.Library.Sceneries.Single(); + var picked = current.Trainsets[1]; + state.CurrentScenery = current; + state.CurrentTrainset = picked; + state.StartingVehicleName = "v2"; + + files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 ")); + var change = watch.Look(installation.Library.Sceneries).Single(); + SceneryFileWatch.CarryOver(change.Current!, change.Fresh!); + watch.Apply(change); + + Assert.Same(change.Fresh!.Trainsets[1], state.CurrentTrainset); + Assert.Null(state.StartingVehicleName); + } + + [Fact] + public void A_change_to_a_scenery_reloaded_since_the_look_is_left() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var change = watch.Look(installation.Library.Sceneries).Single(); + installation.Library.ReloadSceneries(); + + Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict); + } + + [Fact] + public void A_change_to_a_scenery_saved_from_here_since_the_look_is_left_and_nothing_carried_over() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var change = watch.Look(installation.Library.Sceneries).Single(); + current.Sources = change.Fresh!.Sources; + + Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict); + Assert.NotSame(current.Trainsets[0], change.Fresh.Trainsets[0]); + } + + [Fact] + public void A_scenery_new_in_the_folder_is_taken() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + + files.WriteAllText(Other, "//$n Lublin\n"); + var change = watch.Look(installation.Library.Sceneries).Single(); + + Assert.Equal(SceneryVerdict.Take, watch.Judge(change).Verdict); + } + + [Fact] + public void A_scenery_new_in_the_folder_that_got_in_since_the_look_is_left() + { + var (installation, watch, _) = Rig(Folder()); + + var change = new SceneryChange(null, installation.Library.Sceneries.Single()); + + Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict); + } + + [Fact] + public void A_change_that_loses_no_edits_is_taken_with_the_edits_carried_over() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var edited = installation.Library.Sceneries.Single().Trainsets[1]; + edited.ReadyToGo = true; + + files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne")); + var change = watch.Look(installation.Library.Sceneries).Single(); + + var judgement = watch.Judge(change); + + Assert.Equal(SceneryVerdict.Take, judgement.Verdict); + Assert.Empty(judgement.Lost); + Assert.Same(edited, change.Fresh!.Trainsets[1]); + } + + [Fact] + public void A_change_that_loses_edits_is_asked_about_naming_them() + { + var files = Folder(); + var (installation, watch, _) = Rig(files); + var current = installation.Library.Sceneries.Single(); + var edited = current.Trainsets[1]; + edited.ReadyToGo = true; + + files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 ")); + var change = watch.Look(installation.Library.Sceneries).Single(); + + var judgement = watch.Judge(change); + + Assert.Equal(SceneryVerdict.AskFirst, judgement.Verdict); + Assert.Same(edited, Assert.Single(judgement.Lost)); + Assert.Same(current.Trainsets[0], change.Fresh!.Trainsets[0]); + } + + [Fact] + public void A_scenery_gone_with_edits_in_it_stays_for_them_to_be_saved_elsewhere() + { + var (installation, watch, _) = Rig(Folder()); + var current = installation.Library.Sceneries.Single(); + current.Trainsets[1].ReadyToGo = true; + + Assert.Equal(SceneryVerdict.Leave, watch.Judge(new SceneryChange(current, null)).Verdict); + } + + [Fact] + public void A_scenery_gone_with_no_edits_in_it_is_taken_off() + { + var (installation, watch, _) = Rig(Folder()); + var current = installation.Library.Sceneries.Single(); + + Assert.Equal(SceneryVerdict.Take, watch.Judge(new SceneryChange(current, null)).Verdict); + } + + [Fact] + public void A_scenery_gone_takes_what_was_picked_with_it() + { + var files = Folder(); + var (installation, watch, state) = Rig(files); + var current = installation.Library.Sceneries.Single(); + state.CurrentScenery = current; + state.CurrentTrainset = current.Trainsets[0]; + state.StartingVehicleName = "v1"; + + watch.Apply(new SceneryChange(current, null)); + + Assert.Empty(installation.Library.Sceneries); + Assert.Null(state.CurrentScenery); + Assert.Null(state.CurrentTrainset); + Assert.Null(state.StartingVehicleName); + } +} diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs index 5a5dd84..75cd996 100644 --- a/StarterNG.Tests/SceneryParserTests.cs +++ b/StarterNG.Tests/SceneryParserTests.cs @@ -1,13 +1,10 @@ -using StarterNG.Infrastructure.Sceneries; +using StarterNG.Domain.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() { @@ -19,7 +16,7 @@ public class SceneryParserTests //$i images/kato.jpg """; - var scenery = Parser().Parse("scenery/kato.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn); Assert.Equal("Śląsk", scenery.Group); Assert.Equal("Osobowy do Katowic", scenery.Name); @@ -32,7 +29,7 @@ public class SceneryParserTests [Fact] public void The_a_directive_marks_a_scenery_archival() { - var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n"); + var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n"); Assert.True(scenery.Archival); } @@ -46,7 +43,7 @@ public class SceneryParserTests //$f doc/notes.txt """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); Assert.Collection(scenery.Attachments, first => @@ -76,7 +73,7 @@ public class SceneryParserTests FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); var include = Assert.Single(scenery.Includes); Assert.Equal("zima.inc", include.FilePath); @@ -95,7 +92,7 @@ public class SceneryParserTests endoptional """; - var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes); + var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes); Assert.Equal(2, include.Kind); Assert.False(include.Selected); @@ -112,7 +109,7 @@ public class SceneryParserTests FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); var trainset = Assert.Single(scenery.Trainsets); Assert.True(trainset.Parsed); @@ -126,11 +123,11 @@ public class SceneryParserTests 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 + node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic FirstInit """; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); var vehicle = Assert.Single(scenery.LooseVehicles); Assert.Equal("wagon_1", vehicle.Name); @@ -142,13 +139,43 @@ public class SceneryParserTests [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"; + const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n"; - var scenery = Parser().Parse("scenery/x.scn", scn); + var scenery = TestScenery.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")); + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line)); Assert.Contains("enddynamic", scenery.Template); } + + [Fact] + public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own() + { + const string included = """ + //$n Auta + //$l Inne + //$d Opis aut + //$f doc/auta.pdf Auta + //$i auta.jpg + //$a + trainset auto road1 0 50 + endtrainset + """; + string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n", + name => (name, included)); + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", text); + + Assert.Equal("Opis scenerii", scenery.Description); + Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath); + Assert.Null(scenery.Name); + Assert.Null(scenery.Group); + Assert.Null(scenery.ImageName); + Assert.False(scenery.Archival); + // The file's own trainset is still listed, and its text still written for a start. + Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile); + Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom())); + } } diff --git a/StarterNG.Tests/ScenerySyntaxTests.cs b/StarterNG.Tests/ScenerySyntaxTests.cs new file mode 100644 index 0000000..3052cd3 --- /dev/null +++ b/StarterNG.Tests/ScenerySyntaxTests.cs @@ -0,0 +1,382 @@ +using StarterNG.Domain.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class ScenerySyntaxTests +{ + private static (SceneryFaultKind, string, int, string?, string?) Seen(SceneryFault fault) => + (fault.Kind, fault.File, fault.Line, fault.Subject, fault.Token); + + [Fact] + public void A_trainset_commented_out_is_no_trainset() + { + const string scn = """ + /* trainset stary tor_1 0 0 + node -1 0 st dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset */ + // trainset dla gracza: + trainset pociag tor_1 10 0 + node -1 0 ep07-001 dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic//koniec + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + var trainset = Assert.Single(scenery.Trainsets); + Assert.Equal("pociag", trainset.Name); + Assert.Single(trainset.Vehicles); + Assert.Empty(scenery.Faults); + Assert.Contains("trainset stary", scenery.Template); + } + + [Fact] + public void A_trainset_without_its_end_is_reported_and_the_next_one_read_on_its_own() + { + const string scn = """ + FirstInit + trainset pierwszy tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + trainset drugi tor_2 10 0 + node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + Assert.Equal((SceneryFaultKind.TrainsetNotClosed, "x.scn", 2, "pierwszy", null), + Seen(Assert.Single(scenery.Faults))); + var trainset = Assert.Single(scenery.Trainsets); + Assert.Equal("drugi", trainset.Name); + Assert.Equal("b", Assert.Single(trainset.Vehicles).Name); + // The unended one is the simulator's to read as written. + Assert.Contains("trainset pierwszy", scenery.Template); + } + + [Fact] + public void A_vehicle_without_its_end_is_reported_yet_its_trainset_listed() + { + const string scn = """ + trainset pociag tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 + node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.VehicleNotClosed, "x.scn", 2, "a", null), Seen(fault)); + // The simulator goes on to the next enddynamic, taking b for a's. + Assert.Equal("b", fault.Lost); + Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name)); + } + + [Fact] + public void A_vehicle_with_no_load_count_takes_the_next_one_with_it() + { + const string scn = """ + trainset pociag tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 enddynamic + node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + // Its enddynamic read for the count, "node" for a destination, b's for its end. + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.LoadCountMissing, 2, "a", "b"), (fault.Kind, fault.Line, fault.Subject, fault.Lost)); + Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name)); + } + + [Fact] + public void A_load_with_no_type_runs_on_into_the_next_trainset() + { + const string scn = """ + trainset pierwszy tor_1 10 0 + node -1 0 a dynamic PKP/412Z 412z 412z 0 nobody 3 40 enddynamic + endtrainset + trainset drugi tor_2 10 0 + node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + node -1 0 c dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + // The second endtrainset is what ends the first trainset then. + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.LoadTypeMissing, 2, "a", "40", "endtrainset, trainset drugi, b"), + (fault.Kind, fault.Line, fault.Subject, fault.Token, fault.Lost)); + Assert.Equal(new[] { "pierwszy", "drugi" }, scenery.Trainsets.Select(t => t.Name)); + } + + [Theory] + [InlineData("0 none", "none")] + [InlineData("0 pantstate", "pantstate")] + [InlineData("0 cel Katowice", null)] + [InlineData("3 pantstate", null)] + [InlineData("12 wegiel", null)] + public void One_word_after_the_load_is_taken_for_a_destination_named_enddynamic(string load, string? word) + { + string scn = $""" + trainset pociag tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 {load} enddynamic + node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + endtrainset + """; + + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults; + + if (word is null) + Assert.Empty(faults); + else + Assert.Equal((SceneryFaultKind.WordBeforeEnd, word, "b"), (faults.Single().Kind, faults.Single().Token, faults.Single().Lost)); + } + + [Fact] + public void A_header_written_twice_is_told_as_such() + { + const string scn = """ + trainset baltyk\mod\exe45151 chojnice301 40 0.1 + //$o -Skład ozdobny + trainset baltyk\mod\exe45151 chojnice301 40 0.1 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + Assert.Equal((SceneryFaultKind.TrainsetHeaderRepeated, "x.scn", 1, "baltyk\\mod\\exe45151", null), + Seen(Assert.Single(scenery.Faults))); + Assert.Single(scenery.Trainsets); + } + + [Fact] + public void A_trainset_whose_header_is_swallowed_is_told_at_the_fault_that_began_it() + { + const string scn = """ + trainset pierwszy tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + //trainset bez_naglowka tor_2 0 0 + node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + trainset trzeci tor_3 0 0 + node -1 0 c1 dynamic PKP/EU07 eu07 eu07 0 headdriver 3 0 enddynamic + node -1 0 c2 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + node -1 0 c3 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic + endtrainset + trainset czwarty tor_4 0 0 + node -1 0 d dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + // c2 with no trainset takes c3, and the endtrainset is left alone - all of + // it from b, where the fault is. + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 5, "endtrainset, trainset trzeci, c1", "trzeci"), + (fault.Kind, fault.Line, fault.Lost, fault.Unmade)); + Assert.Equal(new[] { "pierwszy", "trzeci", "czwarty" }, scenery.Trainsets.Select(t => t.Name)); + } + + [Fact] + public void Text_where_a_number_belongs_is_named() + { + const string scn = """ + trainset pociag tor_1 1O 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0,5 headdriver 3 0 enddynamic + endtrainset + """; + + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults; + + Assert.Equal(new[] + { + (SceneryFaultKind.NotANumber, "x.scn", 1, (string?)"pociag", (string?)"1O"), + (SceneryFaultKind.NotANumber, "x.scn", 2, "a", "0,5") + }, faults.Select(Seen)); + } + + [Fact] + public void A_track_name_split_by_a_space_is_named_whole() + { + // "Pawonków#L" with its "ó" lost to a space. + const string scn = """ + trainset none Pawonk w#L 200.0 0.0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + endtrainset + """; + + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults; + + Assert.Equal((SceneryFaultKind.TrackNameSplit, "x.scn", 1, "none", "Pawonk w#L"), Seen(Assert.Single(faults))); + } + + [Fact] + public void Vehicles_left_without_their_trainset_header_are_told_so() + { + const string scn = """ + //trainset 2003r\64040 Staniszcze_Male_po#2 -50 0.1 + node -1 0 EN57-1542ra dynamic pkp\en57_v2 EN57-1542ra EN57RA 0 headdriver 255 0 enddynamic + endtrainset + node -1 0 auto dynamic road\ford transit transit droga-b 0 headdriver 25.0 0 enddynamic + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + // On no track the simulator has, it drops the vehicle and goes on to the + // next enddynamic - past the endtrainset, to the car's. + var fault = Assert.Single(scenery.Faults); + Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 2, "endtrainset, auto"), (fault.Kind, fault.Line, fault.Lost)); + Assert.Equal("auto", Assert.Single(scenery.LooseVehicles).Name); + } + + [Fact] + public void Structure_faults_are_found_where_the_simulator_reports_them() + { + const string scn = """ + endtrainset + trainset pusty tor_1 10 0 + endtrainset trainset pociag tor_1 10 0 + node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver enddynamic + node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic + reversed + endtrainset + include dekoracje.scm // bez end + include auta.scm end + /* bez końca + """; + + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults.Select(f => (f.Kind, f.Line)).ToList(); + + Assert.Equal(new[] + { + (SceneryFaultKind.StrayEndTrainset, 1), + (SceneryFaultKind.VehicleIncomplete, 4), + (SceneryFaultKind.ReversedMisplaced, 6), + (SceneryFaultKind.IncludeNotClosed, 8), + (SceneryFaultKind.UnclosedComment, 10) + }, faults); + } + + [Fact] + public void A_trainset_with_no_vehicles_is_listed_to_be_filled_in_the_depot() + { + const string scn = """ + trainset td/rozklad tdo_n1734 0.0 0.1 + //$o Skład gotowy na żeberku. + endtrainset + include koniec.scm + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true)); + + Assert.Empty(scenery.Faults); + Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name); + } + + [Fact] + public void Reversed_may_follow_the_assignments_ahead_of_the_vehicles() + { + const string scn = """ + Trainset pociag tor_1 10 0 + assignment pl "Pociąg" endassignment + reversed + Node -1 0 a dynamic PKP/EP07 ep07 ep07 0 HeadDriver 3 0 EndDynamic + EndTrainset + """; + + var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn); + + Assert.Empty(scenery.Faults); + var trainset = Assert.Single(scenery.Trainsets); + Assert.True(trainset.Reversed); + Assert.Equal(StarterNG.Classes.eDriverType.Headdriver, Assert.Single(trainset.Vehicles).DriverType); + } + + [Fact] + public void A_fault_in_an_included_file_is_placed_in_that_file() + { + string text = SceneryIncludes.Inline( + "FirstInit\ninclude tomaszewo/auta.scm end\ntrainset zly tor 0\nendtrainset\n", + name => (name, "// auta\ntrainset auto road1 0 50\nnode -1 0 a1 dynamic car car car x headdriver 3 0 enddynamic\nendtrainset")); + + var faults = TestScenery.Parser().Parse("scenery/x.scn", text).Faults.Select(Seen); + + Assert.Equal(new[] + { + (SceneryFaultKind.NotANumber, "tomaszewo/auta.scm", 3, (string?)"a1", (string?)"x"), + (SceneryFaultKind.TrainsetIncomplete, "x.scn", 3, "zly", null) + }, faults); + } + + [Fact] + public void Missing_includes_are_reported_but_optional_ones_and_templates_are_not() + { + const string scn = """ + include tory.scm end + include brak.scm end // komentarz + include budynek.inc (p1) 0 0 end + include $(p1)_dom.inc end + include opcja.scm end //$optional 2,Opcja + endoptional + """; + + var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults; + + Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults))); + } + + [Fact] + public void An_include_named_in_another_case_is_found_as_the_simulator_finds_it() + { + var files = new InMemoryFileSystem(caseSensitive: true) + .WithLegacyFile("game/scenery/x.scn", "FirstInit\ninclude Tomaszewo/Auta.SCM end\n") + .WithLegacyFile("game/scenery/tomaszewo/auta.scm", + "trainset auto road1 0 50\nnode -1 0 a1 dynamic car car car 0 headdriver 3 0 enddynamic\nendtrainset\n"); + var repository = TestScenery.Repository(files); + + var scenery = repository.Load("game/scenery/x.scn")!; + + Assert.Empty(scenery.Faults); + Assert.Equal("tomaszewo/auta.scm", Assert.Single(scenery.Trainsets).SourceFile); + } + + [Fact] + public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses() + { + // The simulator folds the name to lower case and opens it so. + var files = new InMemoryFileSystem(caseSensitive: true) + .WithLegacyFile("game/scenery/braniewo_szeroki.scn", + "include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n") + .WithFile("game/scenery/Braniewo/Base.ctr", "") + .WithFile("game/scenery/braniewo/tory.scm", ""); + var repository = TestScenery.Repository(files); + + var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults; + + Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"), + Seen(Assert.Single(faults))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt) + { + var files = new InMemoryFileSystem(caseSensitive: true) + .WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n"); + if (sbt) + files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07"); + var repository = TestScenery.Repository(files); + + var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!; + + Assert.Equal(sbt, scenery.HasCompanionTimetable); + Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count); + } +} diff --git a/StarterNG.Tests/SceneryTrainsetSaveTests.cs b/StarterNG.Tests/SceneryTrainsetSaveTests.cs new file mode 100644 index 0000000..1d2aedc --- /dev/null +++ b/StarterNG.Tests/SceneryTrainsetSaveTests.cs @@ -0,0 +1,578 @@ +using System.Reflection; +using System.Text; +using StarterNG.Application; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SceneryTrainsetSaveTests +{ + private const string Path = "scenery/test.scn"; + + private const string Scn = + "//$n Bałtyk\r\n" + + "FirstInit\r\n" + + "trainset pociag tor_1 200.0 0.0\r\n" + + "//$o -Opis składu\r\n" + + "node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" + + "node -1 0 wagon-1 dynamic PKP\\111A b10-1 111a 0 nobody 3 0 enddynamic\r\n" + + "endtrainset\r\n" + + "// koniec pliku z polską literą: żółw\r\n"; + + private static Encoding Cp1250 => Encoding.GetEncoding(1250); + + private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(string scn) => + Setup(Cp1250.GetBytes(scn)); + + private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(byte[] bytes) + { + var files = new InMemoryFileSystem(); + files.WriteAllBytes(Path, bytes); + var scenery = TestScenery.Parser().Parse(Path, LegacyText.Decode(bytes)); + return (files, scenery, new SaveSceneryTrainset(files, TestLog.Over(files))); + } + + [Fact] + public void Reversed_after_the_header_is_read_and_written_back() + { + var trainset = new Trainset( + "trainset a tor 0 0 reversed\n" + + "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" + + "endtrainset"); + + Assert.True(trainset.Reversed); + Assert.Single(trainset.Vehicles); + Assert.StartsWith("trainset a tor 0 0 reversed\n", trainset.GetTrainsetEntry()); + } + + [Fact] + public void A_trainset_without_reversed_is_not_turned() + { + var trainset = new Trainset("trainset a tor 0 0\nendtrainset"); + + Assert.False(trainset.Reversed); + Assert.DoesNotContain("reversed", trainset.GetTrainsetEntry()); + } + + [Fact] + public void An_untouched_trainset_is_not_modified_however_its_numbers_are_spelled() + { + var (_, scenery, _) = Setup(Scn); + + Assert.False(SceneryTrainsetText.IsModified(scenery.Trainsets[0])); + } + + [Fact] + public void The_head_stays_as_written_and_the_vehicles_are_rewritten() + { + var (_, scenery, _) = Setup(Scn.Replace("200.0 0.0\r\n", "200.0 0.0\r\nreversed\r\n")); + var trainset = scenery.Trainsets[0]; + trainset.Vehicles.RemoveAt(1); + + string entry = SceneryTrainsetText.Compose(trainset, "\r\n"); + + Assert.StartsWith("trainset pociag tor_1 200.0 0.0\r\nreversed\r\n//$o -Opis składu\r\nnode", entry); + Assert.Contains("ep07-001", entry); + Assert.DoesNotContain("wagon-1", entry); + Assert.EndsWith("enddynamic\r\nendtrainset", entry); + Assert.DoesNotContain("\r\r", entry); + Assert.DoesNotMatch("[^\r]\n", entry); + } + + [Fact] + public void Only_the_speed_of_the_header_follows_the_ready_switch() + { + var (_, scenery, _) = Setup(Scn); + var trainset = scenery.Trainsets[0]; + trainset.ReadyToGo = true; + + string entry = SceneryTrainsetText.Compose(trainset, "\n"); + + Assert.StartsWith("trainset pociag tor_1 200.0 0.1\n", entry); + Assert.True(SceneryTrainsetText.IsModified(trainset)); + } + + [Fact] + public void Saving_rewrites_the_trainset_alone_and_keeps_the_original_once() + { + var (files, scenery, save) = Setup(Scn); + var trainset = scenery.Trainsets[0]; + trainset.Vehicles.RemoveAt(1); + + var (outcome, _) = save.Execute(scenery, trainset); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, outcome); + string written = Cp1250.GetString(files.ReadAllBytes(Path)); + Assert.StartsWith("//$n Bałtyk\r\nFirstInit\r\ntrainset pociag tor_1 200.0 0.0\r\n//$o -Opis składu\r\n", written); + Assert.DoesNotContain("wagon-1", written); + Assert.EndsWith("endtrainset\r\n// koniec pliku z polską literą: żółw\r\n", written); + Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix))); + Assert.False(SceneryTrainsetText.IsModified(trainset)); + + // A second save finds what the first one wrote; the backup stays the original. + trainset.Vehicles.Clear(); + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, trainset).Outcome); + Assert.DoesNotContain("ep07-001", Cp1250.GetString(files.ReadAllBytes(Path))); + Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix))); + } + + [Fact] + public void Of_two_trainsets_written_alike_the_right_one_is_saved() + { + const string block = + "trainset none tor_1 0 0\r\n" + + "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\r\n" + + "endtrainset\r\n"; + var (files, scenery, save) = Setup(block + "// x\r\n" + block); + var second = scenery.Trainsets[1]; + second.Vehicles.Clear(); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, second).Outcome); + + string written = Cp1250.GetString(files.ReadAllBytes(Path)); + Assert.StartsWith(block + "// x\r\ntrainset none tor_1 0 0\r\nendtrainset", written); + } + + [Fact] + public void A_file_changed_meanwhile_is_left_alone() + { + var (files, scenery, save) = Setup(Scn); + var trainset = scenery.Trainsets[0]; + trainset.Vehicles.Clear(); + files.WithLegacyFile(Path, Scn.Replace("wagon-1", "wagon-2")); + + Assert.Equal(SceneryTrainsetSaveOutcome.NotFound, save.Execute(scenery, trainset).Outcome); + Assert.Contains("wagon-2", Cp1250.GetString(files.ReadAllBytes(Path))); + Assert.False(files.FileExists(Path + SaveSceneryTrainset.BackupSuffix)); + } + + private const string Cars = + "trainset none road1 -20 50\r\n" + + "node -1 0 auto_1 dynamic road\\man man nl293 0 headdriver 0 0 enddynamic\r\n" + + "endtrainset\r\n"; + + private const string Traffic = + "node -1 0 nl293_a dynamic road\\man man nl293 \t\tryp_rd110 \t0 headdriver 50 0 enddynamic\r\n"; + + private const string WithIncludes = + "include tory.scm end\r\n" + + "FirstInit\r\n" + + "trainset pociag tor_1 0 0\r\n" + + "node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" + + "endtrainset\r\n" + + "include tomaszewo/auta.scm end //ruch uliczny\r\n" + + "include tomaszewo/eventy.ctr end\r\n" + + "/* include tomaszewo/auta.scm end */\r\n" + + "include tomaszewo/auta.scm parametr end\r\n"; + + private static (string File, string Text)? Files(string name) => name switch + { + "tory.scm" => (name, Cars), + "tomaszewo/auta.scm" => (name, Cars + Traffic), + "tomaszewo/eventy.ctr" => (name, "event e1 multiple 0 none endevent\r\n"), + _ => null + }; + + [Fact] + public void Includes_after_first_init_that_hold_trainsets_are_put_in_place() + { + string text = SceneryIncludes.Inline(WithIncludes, Files); + + // Before FirstInit is the scenery itself; without trainsets there is nothing to list. + Assert.StartsWith("include tory.scm end\r\n", text); + Assert.Contains("include tomaszewo/eventy.ctr end", text); + Assert.Contains(SceneryIncludes.BeginMarker + "tomaszewo/auta.scm\r\ntrainset none road1", text); + // Commented out, or with parameters to put in: left to the simulator. + Assert.Contains("/* include tomaszewo/auta.scm end */", text); + Assert.Contains("include tomaszewo/auta.scm parametr end", text); + Assert.Single(SceneryIncludes.Ranges(text)); + } + + [Fact] + public void A_trainset_from_an_include_is_listed_with_its_file_and_exported_in_place() + { + var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files)); + + Assert.Equal(2, scenery.Trainsets.Count); + Assert.Null(scenery.Trainsets[0].SourceFile); + Assert.Equal("tomaszewo/auta.scm", scenery.Trainsets[1].SourceFile); + + // Loose road traffic in the file stays put, as written; only its name is known. + Assert.Empty(scenery.LooseVehicles); + Assert.Contains("nl293_a", scenery.LooseVehicleNames); + + string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()); + Assert.Contains("auto_1", exported); + Assert.Contains(Traffic, exported); + Assert.True(exported.IndexOf(Traffic, StringComparison.Ordinal) > exported.IndexOf("FirstInit", StringComparison.Ordinal)); + Assert.DoesNotContain("include tomaszewo/auta.scm end //", exported); + } + + [Fact] + public void A_trainset_from_an_include_is_saved_into_that_file() + { + var files = new InMemoryFileSystem() + .WithLegacyFile(Path, WithIncludes) + .WithLegacyFile("scenery/tomaszewo/auta.scm", Cars + "// koniec\r\n"); + var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files)); + var log = TestLog.Over(files); + var car = scenery.Trainsets[1]; + car.Vehicles.Clear(); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, car).Outcome); + + Assert.Equal("trainset none road1 -20 50\r\nendtrainset\r\n// koniec\r\n", + Cp1250.GetString(files.ReadAllBytes("scenery/tomaszewo/auta.scm"))); + Assert.Equal(WithIncludes, Cp1250.GetString(files.ReadAllBytes(Path))); + Assert.True(files.FileExists("scenery/tomaszewo/auta.scm" + SaveSceneryTrainset.BackupSuffix)); + } + + [Fact] + public void A_utf8_file_stays_utf8_with_its_mark() + { + var (files, scenery, save) = Setup(new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(Scn)).ToArray()); + scenery.Trainsets[0].Vehicles.RemoveAt(1); + + save.Execute(scenery, scenery.Trainsets[0]); + + byte[] bytes = files.ReadAllBytes(Path); + Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes[..3]); + Assert.Contains("żółw", Encoding.UTF8.GetString(bytes[3..])); + } + + private const string V1 = "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic"; + private const string V2 = "node -1 0 v2 dynamic d s m 0 nobody 3 0 enddynamic"; + private const string V3 = "node -1 0 v3 dynamic d s m 0 nobody 3 0 enddynamic"; + + private static string SavedText(string scn, Action? change = null) + { + var (files, scenery, save) = Setup(scn); + change?.Invoke(scenery.Trainsets[0]); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome); + + return Cp1250.GetString(files.ReadAllBytes(Path)); + } + + private static IEnumerable VehicleNames(string scn) => + TestScenery.Parser().Parse(Path, scn).Trainsets.Single().Vehicles.Select(v => v.Name); + + private static void Edit(Trainset trainset) => trainset.Vehicles[0].Coupling.Flags = 5; + + [Fact] + public void A_vehicle_commented_out_stays_in_its_comment() + { + string scn = $"FirstInit\ntrainset a t 0 0\n/*\n{V1}\n*/\n{V2}\nendtrainset\n" + + "node -1 0 poza dynamic d s m none 0 nobody 0 0 enddynamic\n"; + + Assert.Equal(scn, SavedText(scn)); + + string edited = SavedText(scn, Edit); + Assert.Contains($"/*\n{V1}\n*/\n", edited); + var scenery = TestScenery.Parser().Parse(Path, edited); + Assert.Equal(new[] { "v2" }, scenery.Trainsets.Single().Vehicles.Select(v => v.Name)); + Assert.Equal(new[] { "poza" }, scenery.LooseVehicleNames); + } + + [Theory] + [InlineData("trainset a t 0 0\nNode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")] + [InlineData("trainset a t 0 0\nNODE -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")] + [InlineData("trainset a t 0 0 node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")] + [InlineData("trainset a t 0 0 /*x*/node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")] + public void A_vehicle_is_found_however_it_is_written_and_not_written_twice(string scn) + { + Assert.Equal(scn, SavedText(scn)); + + string edited = SavedText(scn, Edit); + var vehicle = Assert.Single(TestScenery.Parser().Parse(Path, edited).Trainsets.Single().Vehicles); + Assert.Equal(5, vehicle.Coupling.Flags); + } + + [Fact] + public void A_vehicle_named_as_a_keyword_is_no_keyword() + { + string scn = "trainset node node 0 0\nnode -1 0 node dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n"; + + Assert.Equal(scn, SavedText(scn)); + Assert.Contains("trainset node node 0 0.1\n", SavedText(scn, t => t.ReadyToGo = true)); + } + + private const string Commented = + "trainset a t 0 0\n" + + V1 + "\n" + + "// o v2\n" + + V2 + " // przy v2\n" + + "/* o v3 */\n" + + V3 + "\n" + + "// koniec\n" + + "EndTrainset\n"; + + [Fact] + public void A_save_with_no_change_leaves_the_vehicles_and_comments_as_they_were() + { + Assert.Equal(Commented, SavedText(Commented)); + + // Nor is anything but the speed touched when only the speed changes. + Assert.Equal(Commented.Replace("t 0 0\n", "t 0 0.1\n"), SavedText(Commented, t => t.ReadyToGo = true)); + } + + [Fact] + public void Comments_stay_where_they_are_when_a_vehicle_is_edited() + { + string edited = SavedText(Commented, t => t.Vehicles[1].Coupling.Flags = 5); + + Assert.Equal(Commented.Replace(V2, V2.Replace("nobody 3", "nobody 5")), edited); + } + + [Fact] + public void Comments_stay_when_a_vehicle_is_removed() + { + Assert.Equal( + "trainset a t 0 0\n" + V1 + "\n// o v2\n// przy v2\n/* o v3 */\n" + V3 + "\n// koniec\nEndTrainset\n", + SavedText(Commented, t => t.Vehicles.RemoveAt(1))); + + Assert.Equal( + "trainset a t 0 0\n// o v2\n// przy v2\n/* o v3 */\n// koniec\nEndTrainset\n", + SavedText(Commented, t => t.Vehicles.Clear())); + } + + [Fact] + public void Comments_stay_when_a_vehicle_is_added() + { + var added = new Dynamic { Name = "nowy", DataFolder = "d", SkinFile = "s", MmdFile = "m" }; + + Assert.Equal( + Commented.Replace(V3, added.ToTrainsetNode() + V3), + SavedText(Commented, t => t.Vehicles.Insert(2, added))); + + Assert.Equal( + Commented.Replace("EndTrainset", added.ToTrainsetNode() + "EndTrainset"), + SavedText(Commented, t => t.Vehicles.Add(added))); + } + + [Fact] + public void Comments_stay_when_vehicles_change_places_and_a_moved_one_stays_as_written() + { + string moved = "node 500 0 v3 dynamic d s m /*w srodku*/ 0 nobody 3 0 enddynamic"; + string scn = Commented.Replace(V3, moved); + + string saved = SavedText(scn, t => t.Vehicles.Reverse()); + + Assert.Equal(new[] { "v3", "v2", "v1" }, VehicleNames(saved)); + foreach (string comment in new[] { "// o v2\n", " // przy v2\n", "/* o v3 */\n", "// koniec\n" }) + Assert.Contains(comment, saved); + Assert.Contains(moved, saved); + } + + [Theory] + [InlineData("trainset a /*tor*/ t 100 0\nendtrainset\n", "trainset a /*tor*/ t 100 0.1\nendtrainset\n")] + [InlineData("trainset a t /*x*/ 100/*y*/ 0.0 reversed\nendtrainset\n", "trainset a t /*x*/ 100/*y*/ 0.1 reversed\nendtrainset\n")] + [InlineData("Trainset a // tor\n t\n 100\n 0.0\nendtrainset\n", "Trainset a // tor\n t\n 100\n 0.1\nendtrainset\n")] + public void The_speed_is_found_past_a_comment_in_the_header(string scn, string expected) + { + Assert.Equal(expected, SavedText(scn, t => t.ReadyToGo = true)); + } + + [Fact] + public void An_optional_include_after_first_init_stays_an_option() + { + const string scn = "FirstInit\ninclude zima.inc end //$optional 1, Zima\nx\nendoptional\n"; + string content = $"trainset a t 0 0\n{V1}\nendtrainset\n"; + + string text = SceneryIncludes.Inline(scn, name => (name, content)); + + Assert.Equal(scn, text); + var scenery = TestScenery.Parser().Parse(Path, text); + Assert.Equal("Zima", Assert.Single(scenery.Includes).Desc); + Assert.Empty(scenery.Trainsets); + } + + /// A disk that gives out on whatever is being written; what it does to the file is ' business. + public class FailingDisk : DispatchProxy + { + public InMemoryFileSystem Files = null!; + + protected override object? Invoke(MethodInfo? method, object?[]? args) => + method!.Name == nameof(IFileSystem.WriteAllBytesAtomic) + ? throw new IOException("the disk gave out") + : method.Invoke(Files, args); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void A_write_that_fails_leaves_the_file_and_its_backup_whole(bool backedUp) + { + var (files, scenery, _) = Setup(Scn); + string backup = Path + SaveSceneryTrainset.BackupSuffix; + if (backedUp) + files.WithLegacyFile(backup, Scn); + var disk = DispatchProxy.Create(); + ((FailingDisk)(object)disk).Files = files; + var log = TestLog.Over(); + var trainset = scenery.Trainsets[0]; + string before = trainset.RawEntry; + trainset.Vehicles.RemoveAt(1); + + var (outcome, detail) = new SaveSceneryTrainset(disk, log).Execute(scenery, trainset); + + Assert.Equal((SceneryTrainsetSaveOutcome.Failed, "the disk gave out"), (outcome, detail)); + Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path))); + Assert.Equal(backedUp, files.FileExists(backup)); + Assert.Equal(new[] { Path }.Concat(backedUp ? new[] { backup } : Array.Empty()), + files.GetFiles("scenery")); + Assert.Equal(before, trainset.RawEntry); + } + + private static (string Scn, SaveSceneryTrainset Save, Scenery Scenery, string Folder) OnDisk(string text) + { + string folder = Directory.CreateTempSubdirectory("starter-save-").FullName; + string scn = System.IO.Path.Combine(folder, "test.scn"); + var files = new PhysicalFileSystem(); + files.WriteAllText(scn, text, LegacyText.CodePage1250); + var log = TestLog.Over(); + return (scn, new SaveSceneryTrainset(files, log), TestScenery.Parser().Parse(scn, text), folder); + } + + [Fact] + public void On_disk_the_new_text_is_written_beside_the_file_and_moved_over_it() + { + var (scn, save, scenery, folder) = OnDisk(Scn); + try + { + var trainset = scenery.Trainsets[0]; + trainset.Vehicles.RemoveAt(1); + + // Nothing can be written beside it: the file is not touched either. + string temp = scn + PhysicalFileSystem.TempSuffix; + Directory.CreateDirectory(temp); + Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, trainset).Outcome); + Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn))); + Assert.True(File.Exists(scn + SaveSceneryTrainset.BackupSuffix)); + + Directory.Delete(temp); + Assert.Equal((SceneryTrainsetSaveOutcome.Saved, (string?)null), save.Execute(scenery, trainset)); + + Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(scn))); + Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn + SaveSceneryTrainset.BackupSuffix))); + Assert.Equal( + new[] { "test.scn", "test.scn" + SaveSceneryTrainset.BackupSuffix }, + Directory.GetFiles(folder, "test.scn*").Select(file => System.IO.Path.GetFileName(file)).Order()); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public void On_disk_a_file_that_cannot_be_replaced_stays_and_nothing_is_left_beside_it() + { + // Only Windows keeps a file in use from being moved over. + if (!OperatingSystem.IsWindows()) + return; + + var (scn, save, scenery, folder) = OnDisk(Scn); + try + { + scenery.Trainsets[0].Vehicles.RemoveAt(1); + + using (new FileStream(scn, FileMode.Open, FileAccess.Read, FileShare.Read)) + Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, scenery.Trainsets[0]).Outcome); + + Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn))); + Assert.False(File.Exists(scn + PhysicalFileSystem.TempSuffix)); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public void On_disk_a_link_stays_a_link_and_its_file_gets_the_text() + { + var (scn, save, scenery, folder) = OnDisk(Scn); + try + { + string target = scn + ".wlasciwy"; + File.Move(scn, target); + try + { + File.CreateSymbolicLink(scn, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Windows lets only some users make links. + return; + } + scenery.Trainsets[0].Vehicles.RemoveAt(1); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome); + + Assert.NotNull(new FileInfo(scn).LinkTarget); + Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(target))); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + private static byte[] Bytes(params object[] parts) => + parts.SelectMany(part => part as byte[] ?? Encoding.ASCII.GetBytes((string)part)).ToArray(); + + [Fact] + public void Bytes_behind_a_mark_that_are_not_utf8_stay_as_they_were() + { + byte[] mark = { 0xEF, 0xBB, 0xBF }; + byte[] polish = { 0xB3, 0xF3, 0xBF }; // "łóż" in code page 1250 + string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n"; + var (files, scenery, save) = Setup(Bytes(mark, "// ", polish, "\n", trainset, "// ", polish, "\n")); + scenery.Trainsets[0].Vehicles.Clear(); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome); + + Assert.Equal(Bytes(mark, "// ", polish, "\ntrainset a t 0 0\nendtrainset\n// ", polish, "\n"), files.ReadAllBytes(Path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Every_byte_outside_the_vehicles_stays_as_it_was(bool marked) + { + byte[] mark = marked ? new byte[] { 0xEF, 0xBB, 0xBF } : Array.Empty(); + byte[] all = Enumerable.Range(0, 256).Select(b => (byte)b).ToArray(); + string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n"; + var (files, scenery, save) = Setup(Bytes(mark, trainset, all)); + scenery.Trainsets[0].Vehicles.Clear(); + + Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome); + + Assert.Equal(Bytes(mark, "trainset a t 0 0\nendtrainset\n", all), files.ReadAllBytes(Path)); + } + + [Theory] + // Its own line ends, in a file with the other kind elsewhere. + [InlineData("// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n")] + // Both kinds in the trainset. + [InlineData("trainset a t 0 0\r\n// x\n" + V1 + "\r\nendtrainset\r\n")] + public void Mixed_line_ends_stay_as_they_were(string scn) + { + Assert.Equal(scn, SavedText(scn)); + } + + [Fact] + public void What_the_starter_writes_takes_the_line_ends_of_the_trainset() + { + string scn = "// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n"; + + Assert.Equal( + "// x\r\ntrainset a t 0 0\n" + V2 + "\nendtrainset\n// y\r\n", + SavedText(scn, t => t.Vehicles[0].Name = "v2")); + } +} diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs index 4e968c4..35a313e 100644 --- a/StarterNG.Tests/SceneryWeatherTests.cs +++ b/StarterNG.Tests/SceneryWeatherTests.cs @@ -1,13 +1,10 @@ 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() { @@ -21,7 +18,7 @@ public class SceneryWeatherTests time 8:30 0 0 endtime """; - var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather; + var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather; Assert.True(weather.IsAuthored); Assert.Equal(172, weather.Day); @@ -35,7 +32,7 @@ public class SceneryWeatherTests [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; + var weather = TestScenery.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); @@ -44,7 +41,7 @@ public class SceneryWeatherTests [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; + var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; Assert.False(weather.Changed); } @@ -52,7 +49,7 @@ public class SceneryWeatherTests [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; + var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather; weather.Day = 5; weather.Temperature = -12; @@ -84,4 +81,168 @@ public class SceneryWeatherTests Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom())); Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom())); } + + private static string Rewritten(string scn) => + SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom()); + + private static string Code(string text) => SceneryCode.Mask(text, out _); + + /// The block the writer puts at the top of the text, and the rest. + private static (string Written, string Others) Split(string rewritten) + { + const string end = "endtime\r\n"; + int at = rewritten.IndexOf(end, StringComparison.Ordinal) + end.Length; + return (rewritten[..at], rewritten[at..]); + } + + [Fact] + public void A_config_commented_out_stays_off_and_the_one_after_it_is_kept() + { + // As linia61_osobowy2.scn has them. + const string commented = "//config pause yes endconfig //yes: zatrzymanie symulacji zaraz po wczytaniu, jeśli start jako jadący"; + string scn = $""" + sky skydome_clouds.t3d endsky + atmo 0 0 0 1000 2000 0.341 0.408 0.471 -1.2 endatmo + time 6:43 06:07 21:02 endtime //w Lublińcu 7:30 + + {commented} + config hiddenevents 1 endconfig + + include linia61/czestochowa.scm end + + """; + + var (written, rest) = Split(Rewritten(scn)); + + Assert.Contains("hiddenevents 1\r\n", written); + Assert.DoesNotContain("pause", written); + Assert.Contains(commented, rest); + Assert.Contains("//w Lublińcu 7:30", rest); + Assert.Contains("sky skydome_clouds.t3d endsky", rest); + Assert.Contains("include linia61/czestochowa.scm end", rest); + Assert.DoesNotContain("hiddenevents", rest); + foreach (string word in new[] { "atmo", "time", "pause" }) + Assert.DoesNotContain(word, Code(rest)); + } + + [Fact] + public void A_keyword_in_a_comment_does_not_cut_up_to_the_next_block() + { + const string scn = """ + // atmo: tu byla mgla + include a.scm end + {{0}} + /* config */ + include b.scm end + {{1}} + // time 8 tu nie + include c.scm end + atmo 0 0 0 10 2000 0 0 0 0 endatmo + config hiddenevents 1 endconfig + time 8:30 0 0 endtime + """; + + var (written, rest) = Split(Rewritten(scn)); + + Assert.Contains("hiddenevents 1", written); + foreach (string kept in new[] + { + "// atmo: tu byla mgla", "include a.scm end", "{{0}}", "/* config */", + "include b.scm end", "{{1}}", "// time 8 tu nie", "include c.scm end" + }) + Assert.Contains(kept, rest); + foreach (string gone in new[] { "endatmo", "endconfig", "endtime" }) + Assert.DoesNotContain(gone, rest); + } + + [Theory] + [InlineData("/* config a 1 endconfig */")] + [InlineData("// config a 1 endconfig")] + [InlineData("/* atmo 0 0 0 1 2 0 0 0 0 endatmo */")] + [InlineData("// atmo 0 0 0 1 2 0 0 0 0 endatmo")] + [InlineData("/* time 8:30 0 0 endtime */")] + [InlineData("// time 8:30 0 0 endtime")] + [InlineData("// movelight 100")] + [InlineData("/*\nmovelight 100\n*/")] + [InlineData("// scenario.weather.temperature 5")] + [InlineData("/* scenario.time.override 6:00 */")] + public void What_is_commented_out_stays_as_it_is(string comment) + { + var (written, rest) = Split(Rewritten($"x\n{comment}\ny\n")); + + Assert.Equal($"x\n{comment}\ny\n", rest); + Assert.DoesNotContain("a 1", written); + } + + [Fact] + public void A_comment_in_a_block_stays_when_the_block_goes() + { + const string scn = "atmo 0 0 0 // mgla\n10 2000 0 0 0 /* zachmurzenie */ 0 endatmo\n" + + "config\n// uwaga\nmovelight 5 // lato\nhiddenevents 1\nendconfig\ninclude a.scm end\n"; + + var (written, rest) = Split(Rewritten(scn)); + + foreach (string comment in new[] { "// mgla\n", "/* zachmurzenie */", "// uwaga\n", "// lato\n" }) + Assert.Contains(comment, rest); + foreach (string word in new[] { "atmo", "2000", "config", "movelight", "hiddenevents" }) + Assert.DoesNotContain(word, Code(rest)); + Assert.Contains("hiddenevents 1\r\n", written); + Assert.Contains("include a.scm end", rest); + } + + [Fact] + public void Settings_of_every_config_are_carried_and_only_the_starters_own_are_replaced() + { + const string scn = "config movelight 5 hiddenevents 1 endconfig\nCONFIG\nscenario.weather.temperature 3 pause no\nENDCONFIG\n"; + + var (written, rest) = Split(Rewritten(scn)); + + Assert.Contains("hiddenevents 1\r\n", written); + Assert.Contains("pause no\r\n", written); + Assert.DoesNotContain("movelight 5", written); + Assert.DoesNotContain("temperature 3", written); + Assert.Equal("", rest.Trim()); + } + + [Fact] + public void A_word_in_an_include_s_path_is_no_keyword() + { + const string scn = """ + include atmo/lasy.scm end + include time/8.scm end + include config/a.scm end + atmo 0 0 0 10 2000 0 0 0 0 endatmo + config hiddenevents 1 endconfig + """; + + var (_, rest) = Split(Rewritten(scn)); + + Assert.Contains("include atmo/lasy.scm end", rest); + Assert.Contains("include time/8.scm end", rest); + Assert.Contains("include config/a.scm end", rest); + Assert.DoesNotContain("endatmo", rest); + } + + [Fact] + public void Weather_in_comments_is_not_read() + { + const string scn = """ + // movelight 7 + // scenario.weather.temperature 40 + // scenario.time.override 3:33 + /* time 4:44 0 0 endtime */ + // atmo 5 mgla w l61 + include l61/a.scm end + movelight 100 + atmo 0 0 0 50 900 0 0 0 0.7 endatmo + """; + + var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", scn).Weather; + + Assert.Equal(100, weather.Day); + Assert.NotEqual(40, weather.Temperature); + Assert.Equal("17:42", weather.ScenarioTimeOverride); + Assert.Equal(50, weather.FogStart); + Assert.Equal(0.7, weather.Overcast); + } } diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs index 031b05b..4990c73 100644 --- a/StarterNG/Application/Abstractions/ISceneryRepository.cs +++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs @@ -11,4 +11,7 @@ public interface ISceneryRepository IReadOnlyList LoadAll(IProgress? progress = null); Scenery? Load(string path); + + /// The scenery files there are now; null while their folder is away. + IReadOnlyList? ListFiles(); } diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs index 1f94b67..4b76b31 100644 --- a/StarterNG/Application/GameLibrary.cs +++ b/StarterNG/Application/GameLibrary.cs @@ -47,10 +47,19 @@ public sealed class GameLibrary return; progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); - if (_vehicles.Load(Vehicles) == 0) - _log.Log("dynamic: brak plikow textures.txt"); - Parallel.Invoke(_minis.Preload, _physics.Preload); + // Whatever goes wrong with the rolling stock, the sceneries still get their turn. + try + { + if (_vehicles.Load(Vehicles) == 0) + _log.Log("dynamic: brak plikow textures.txt"); + + Parallel.Invoke(_minis.Preload, _physics.Preload); + } + catch (Exception ex) + { + _log.Log("dynamic: loading the rolling stock", ex); + } ReloadSceneries(progress is null ? null diff --git a/StarterNG/Application/SaveSceneryTrainset.cs b/StarterNG/Application/SaveSceneryTrainset.cs new file mode 100644 index 0000000..01d281d --- /dev/null +++ b/StarterNG/Application/SaveSceneryTrainset.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Application; + +public enum SceneryTrainsetSaveOutcome +{ + Saved, + + /// The file no longer holds the trainset as it was read - edited outside meanwhile. + NotFound, + + Failed +} + +/// +/// Writes a consist edited in the depot into the scenery file itself, so it is +/// there the next time too - not only in the copy written for one start. The +/// file's first version is kept beside it, once - under a name of the starter's +/// own, since the simulator's editor keeps its .scn.bak there and rewrites +/// it on every save. +/// +public sealed class SaveSceneryTrainset +{ + public const string BackupSuffix = ".starter.bak"; + + private readonly IFileSystem _files; + private readonly IDiagnosticsLog _log; + + public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log) + { + _files = files; + _log = log; + } + + public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset) + { + string path = FileOf(scenery, trainset); + try + { + byte[] bytes = _files.ReadAllBytes(path); + var (encoding, bom) = LegacyText.Detect(bytes); + string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length); + + // The trainset's own line ends, if it has any: a file can hold both kinds. + string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text; + string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + string entry = SceneryTrainsetText.Compose(trainset, newline); + var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList(); + if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated) + return (SceneryTrainsetSaveOutcome.NotFound, null); + + string backup = path + BackupSuffix; + if (!_files.FileExists(backup)) + _files.WriteAllBytesAtomic(backup, bytes); + + _files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray()); + + // Written here, so no change from outside for the scenery to follow. + scenery.Sources = new Dictionary(scenery.Sources) { [path] = FileStamp.Of(_files, path) }; + + // What the file holds now is what the next save looks for. + trainset.RawEntry = entry; + trainset.OriginalVelocity = trainset.Velocity; + return (SceneryTrainsetSaveOutcome.Saved, null); + } + catch (Exception ex) + { + _log.Log($"saving trainset {trainset.Name} to {path}", ex); + return (SceneryTrainsetSaveOutcome.Failed, ex.Message); + } + } + + /// + /// The scenery file, or the included one the trainset came from - named, as + /// includes are, from the scenery folder the scenery itself is in. + /// + public static string FileOf(Scenery scenery, Trainset trainset) => + trainset.SourceFile is { } file + ? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/')) + : scenery.Path; +} diff --git a/StarterNG/Application/SceneryFileWatch.cs b/StarterNG/Application/SceneryFileWatch.cs new file mode 100644 index 0000000..fc49207 --- /dev/null +++ b/StarterNG/Application/SceneryFileWatch.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Application; + +/// What became of a scenery's files since they were read. +/// The scenery as it is held; null for a file new in the folder. +/// As its files have it now; null for one gone from the folder. +public sealed record SceneryChange(Scenery? Current, Scenery? Fresh); + +public enum SceneryVerdict +{ + /// Nothing to do about it, for now or for good. + Leave, + + /// The library can take it in. + Take, + + /// Taking it in loses edits, so that is for someone to say. + AskFirst +} + +/// What is to become of a change. +/// The consists with unsaved edits that is about. +public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList Lost) +{ + public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty()); + public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty()); +} + +/// +/// Keeps the sceneries in step with their files when those change outside the +/// starter - in a text editor, in the simulator's editor - without anyone having +/// to ask for a reload. +/// +public sealed class SceneryFileWatch +{ + private readonly IFileSystem _files; + private readonly ISceneryRepository _sceneries; + private readonly GameLibrary _library; + private readonly AppState _state; + + // Files that would not load, and how they stood then: not tried, and not logged, + // again before something about them changes. + private readonly Dictionary _unreadable = new(); + + private HashSet _away = new(); + + public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state) + { + _files = files; + _sceneries = sceneries; + _library = library; + _state = state; + } + + /// What has changed among the files of . Off the UI thread, one look at a time. + public IReadOnlyList Look(IReadOnlyList held) + { + var changes = new List(); + if (_sceneries.ListFiles() is not { } files) + return changes; + + var there = files.ToHashSet(); + var away = new HashSet(); + foreach (var scenery in held) + { + if (!there.Contains(scenery.Path)) + { + // A file being replaced is away for a moment: gone only if still so next time. + away.Add(scenery.Path); + if (_away.Contains(scenery.Path)) + changes.Add(new SceneryChange(scenery, null)); + } + else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh) + { + changes.Add(new SceneryChange(scenery, fresh)); + } + } + _away = away; + + var known = held.Select(scenery => scenery.Path).ToHashSet(); + foreach (string file in files.Where(file => !known.Contains(file))) + if (LoadOnce(file, new[] { file }) is { } added) + changes.Add(new SceneryChange(null, added)); + + return changes; + } + + /// Whether any of the files it was read from is no longer as it was. + public bool IsStale(Scenery scenery) => + scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value); + + private Scenery? LoadOnce(string path, IEnumerable sources) + { + string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source))); + if (_unreadable.TryGetValue(path, out string? before) && before == stood) + return null; + + var loaded = _sceneries.Load(path); + if (loaded is null) + _unreadable[path] = stood; + else + _unreadable.Remove(path); + return loaded; + } + + /// + /// Puts what was held into the scenery read anew. A consist whose text is still + /// what it was goes over as the object it is, with its edits and its undo steps; + /// the choices made by hand go with them. + /// + /// The consists with unsaved edits that have no place there: their text changed too. + public static IReadOnlyList CarryOver(Scenery current, Scenery fresh) + { + var waiting = current.Trainsets.ToList(); + for (int i = 0; i < fresh.Trainsets.Count; i++) + { + var read = fresh.Trainsets[i]; + int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry); + if (at < 0) + continue; + + fresh.Trainsets[i] = waiting[at]; + waiting.RemoveAt(at); + } + + fresh.Weather.KeepChoicesOf(current.Weather); + foreach (var include in fresh.Includes) + if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier) + include.Selected = earlier.Selected; + + return waiting.Where(SceneryTrainsetText.IsModified).ToList(); + } + + /// + /// On the UI thread, once for each change and just before acting on it: what a + /// look found may be out of date by then. A scenery read anew has what was held + /// put into it here. + /// + public SceneryJudgement Judge(SceneryChange change) + { + var sceneries = _library.Sceneries; + if (change.Current is not { } current) + return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take; + + // Saved from here since that look, or reloaded by hand. + if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current))) + return SceneryJudgement.Leave; + + // One whose file went stays with what was edited in it, for that to be saved elsewhere. + if (change.Fresh is not { } fresh) + return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take; + + var lost = CarryOver(current, fresh); + return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take; + } + + /// On the UI thread: the library, and what is picked in it, take the change in. + public void Apply(SceneryChange change) + { + var sceneries = _library.Sceneries; + if (change.Current is not { } current) + { + sceneries.Add(change.Fresh!); + return; + } + + if (change.Fresh is not { } fresh) + { + sceneries.Remove(current); + if (ReferenceEquals(_state.CurrentScenery, current)) + { + _state.CurrentScenery = null; + _state.CurrentTrainset = null; + _state.StartingVehicleName = null; + } + return; + } + + sceneries[sceneries.IndexOf(current)] = fresh; + if (ReferenceEquals(_state.CurrentScenery, current)) + Follow(current, fresh); + } + + private void Follow(Scenery current, Scenery fresh) + { + // Its text changed, the one of its name stands for it, or the one in its place. + var picked = _state.CurrentTrainset; + var kept = picked is null || fresh.Trainsets.Contains(picked) + ? picked + : fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name) + ?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked)); + + _state.CurrentScenery = fresh; + _state.CurrentTrainset = kept; + if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName)) + _state.StartingVehicleName = null; + } +} diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index e17c464..0223661 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; +using StarterNG.Domain.Sceneries; namespace StarterNG.Classes; @@ -58,6 +59,28 @@ public class Trainset public string? Mini; + /// + /// "reversed" ahead of the vehicles: the simulator turns the whole trainset round - + /// the first vehicle stays at the offset and leads towards the track's point 1, + /// every vehicle turned. The author's to set; the starter keeps it as found. + /// + public bool Reversed { get; private set; } + + /// + /// The file the trainset is written in, as the scenery's include names it, for + /// one from a file put in place by ; + /// null for the scenery's own. + /// + public string? SourceFile { get; init; } + + /// + /// Where the speed, each vehicle and the endtrainset stand in the text, read + /// word by word as the fields above are - comments left out, a word taken for a + /// field whatever it says. With no endtrainset the end is the end of the text. + /// Known only of a trainset that was . + /// + internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; } + public Trainset(string trainsetEntry) { RawEntry = trainsetEntry; @@ -77,11 +100,7 @@ public class Trainset private void ParseEntry(string trainsetEntry) { - List tokens = Regex - .Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+") - .Select(m => m.Value) - .Where(t => !t.StartsWith("//") && !t.StartsWith("/*")) - .ToList(); + List tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _)); var match = Regex.Match( trainsetEntry, @@ -99,66 +118,92 @@ public class Trainset if (mini.Success) Mini = mini.Groups[1].Value.Trim(); + // The simulator reads its words in lower case: "Trainset" is a trainset too. + bool Is(int at, string word) => tokens[at].Is(word); + + SceneryCode.Token? speed = null; + var spans = new List<(int Start, int End)>(); + int end = trainsetEntry.Length; + for (int i = 0; i < tokens.Count; i++) { - if (tokens[i] == "endtrainset") - break; - - if (tokens[i] == "trainset") + if (Is(i, "endtrainset")) { - this.Name = tokens[++i]; - this.Track = tokens[++i]; - this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); - this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture); + end = tokens[i].Index; + break; + } + + if (Is(i, "trainset")) + { + this.Name = tokens[++i].Text; + this.Track = tokens[++i].Text; + this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture); + speed = tokens[++i]; + this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture); this.OriginalVelocity = this.Velocity; continue; } - if (tokens[i] == "assignments") + // Ahead of the vehicles; the simulator refuses it after the first one. + if (Is(i, "reversed")) { - while (i < tokens.Count && tokens[i] != "endassignment") - i++; - i++; + Reversed |= Vehicles.Count == 0; continue; } - if (tokens[i] == "node") + if (Is(i, "assignment") || Is(i, "assignments")) { + while (i < tokens.Count && !Is(i, "endassignment")) + i++; + continue; + } + + if (Is(i, "node")) + { + int start = tokens[i].Index; Dynamic nodeDynamic = new Dynamic(); - nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture); - nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture); - nodeDynamic.Name = tokens[++i]; + nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture); + nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture); + nodeDynamic.Name = tokens[++i].Text; i++; - nodeDynamic.DataFolder = tokens[++i]; - nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]); - nodeDynamic.MmdFile = tokens[++i]; - nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); + nodeDynamic.DataFolder = tokens[++i].Text; + nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text); + nodeDynamic.MmdFile = tokens[++i].Text; + nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture); - nodeDynamic.DriverType = tokens[++i] switch - { - "headdriver" => eDriverType.Headdriver, - "reardriver" => eDriverType.Reardriver, - "passenger" => eDriverType.Passenger, - _ => eDriverType.Nobody - }; + nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text); - nodeDynamic.Coupling = Coupling.Parse(tokens[++i]); + nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text); + // A missing enddynamic does not take the next vehicle with it. var trailing = new List(); - while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic") - trailing.Add(tokens[++i]); + while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") && + !Is(i + 1, "endtrainset")) + trailing.Add(tokens[++i].Text); nodeDynamic.ReadTrailing(trailing); - i++; + if (i + 1 < tokens.Count && Is(i + 1, "enddynamic")) + i++; Vehicles.Add(nodeDynamic); + spans.Add((start, tokens[i].End)); } } + + Layout = (speed, spans, end); } public string ToSceneryEntry() => Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; + /// No vehicles, in the export too: an entry that could not be read goes out as its raw text. + public void RemoveVehicles() + { + Vehicles = new List(); + if (!Parsed) + RawEntry = string.Empty; + } + public string GetTrainsetEntry() { @@ -177,13 +222,11 @@ public class Trainset sb.Append(Track).Append(' '); sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' '); sb.Append(Velocity.ToString(CultureInfo.InvariantCulture)); + if (Reversed) + sb.Append(" reversed"); sb.Append('\n'); - string entry = sb.ToString(); - foreach (Dynamic vehicle in Vehicles) - entry += vehicle.ToTrainsetNode(); - - return entry; + return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString(); } } @@ -201,12 +244,6 @@ public class Dynamic public Coupling Coupling = new(); - public byte couplingData - { - get => (byte)(Coupling.AbsFlags & 0xFF); - set => Coupling.Flags = value; - } - public const string PantState = "pantstate"; public const int PantStateMax = 3; @@ -244,8 +281,6 @@ public class Dynamic public static bool IsPantStateType(string? type) => string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase); - public bool IsPantState => IsPantStateType(LoadType); - internal void ReadTrailing(List t) { int p = 0; @@ -278,14 +313,33 @@ public class Dynamic return dot > 0 ? token[..dot] : token; } + internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch + { + "headdriver" => eDriverType.Headdriver, + "reardriver" => eDriverType.Reardriver, + "passenger" => eDriverType.Passenger, + _ => eDriverType.Nobody + }; + + private string DriverWord => DriverType switch + { + eDriverType.Headdriver => "headdriver", + eDriverType.Reardriver => "reardriver", + eDriverType.Passenger => "passenger", + _ => "nobody" + }; + internal string WriteTrailing() { var sb = new StringBuilder(); - bool loaded = !string.IsNullOrEmpty(LoadType); + bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0; sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture)); - if (loaded && (LoadCount > 0 || IsPantState)) + // The simulator reads a type only after a count above 0. One after a 0 it + // takes for a destination, and with it the next vehicle, up to its enddynamic. + // Pantographs set to none are the simulator's default anyway. + if (loaded) sb.Append(' ').Append(LoadType); return sb.ToString(); @@ -314,37 +368,26 @@ public class Dynamic public string ToTrainsetNode() { - string driver = DriverType switch - { - eDriverType.Headdriver => "headdriver", - eDriverType.Reardriver => "reardriver", - eDriverType.Passenger => "passenger", - _ => "nobody" - }; - return - $"node {RangeMax} {RangeMin} {Name} dynamic " + + $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " + + $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " + $"{DataFolder} {SkinFile} {MmdFile} " + $"{Offset.ToString(CultureInfo.InvariantCulture)} " + - $"{driver} {Coupling}{WriteTrailing()} enddynamic\n"; + $"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n"; } + public static string ToTrainsetNodes(IEnumerable vehicles) => + string.Concat(vehicles.Select(v => v.ToTrainsetNode())); + public string ToLooseNode() { - string driver = DriverType switch - { - eDriverType.Headdriver => "headdriver", - eDriverType.Reardriver => "reardriver", - eDriverType.Passenger => "passenger", - _ => "nobody" - }; string path = string.IsNullOrEmpty(PathName) ? "none" : PathName; string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture); return $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " + $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " + $"{DataFolder} {SkinFile} {MmdFile} {path} " + - $"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" + + $"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" + $"{WriteTrailing()} enddynamic\n"; } } diff --git a/StarterNG/Domain/Sceneries/FileStamp.cs b/StarterNG/Domain/Sceneries/FileStamp.cs new file mode 100644 index 0000000..674bca0 --- /dev/null +++ b/StarterNG/Domain/Sceneries/FileStamp.cs @@ -0,0 +1,23 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Sceneries; + +/// A file as it stood, to tell whether it has been changed since. +public readonly record struct FileStamp(DateTime LastWriteUtc, long Size) +{ + /// Null for one that is not there, or cannot be looked at just now. + public static FileStamp? Of(IFileSystem files, string path) + { + try + { + return files.FileExists(path) + ? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path)) + : null; + } + catch (Exception) + { + return null; + } + } +} diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs index 243ab2f..07b0d32 100644 --- a/StarterNG/Domain/Sceneries/Scenery.cs +++ b/StarterNG/Domain/Sceneries/Scenery.cs @@ -1,11 +1,9 @@ -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; @@ -59,10 +57,16 @@ public sealed class Scenery public List LooseVehicleNames { get; } = new(); - public List Faults { get; } = new(); + public List Faults { get; } = new(); public SceneryWeather Weather { get; } = new(); + /// + /// The files it was read from, as each stood then: its own and those put in place + /// of their includes. Replaced whole, as it is looked at off the UI thread too. + /// + public IReadOnlyDictionary Sources { get; set; } = new Dictionary(); + public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); public string LocalizedDescription(ISceneryTranslations translations) => @@ -81,7 +85,9 @@ public sealed class Scenery for (int i = 0; i < Trainsets.Count; i++) { - string entry = skipDecorTrainsets && Trainsets[i].Decor + // The simulator logs an empty trainset as a bad one and does nothing else with it. + bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0; + string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor) ? string.Empty : Trainsets[i].ToSceneryEntry(); result = result.Replace("{{" + i + "}}", entry); diff --git a/StarterNG/Domain/Sceneries/SceneryCode.cs b/StarterNG/Domain/Sceneries/SceneryCode.cs new file mode 100644 index 0000000..05d8e4e --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryCode.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace StarterNG.Domain.Sceneries; + +/// +/// A scenery's text read the way the simulator's parser reads it: comments - +/// // to the end of the line, /* */ anywhere, even glued to a word - +/// left out, and the rest split into words at spaces, tabs, line ends and +/// semicolons. A trainset commented out is no trainset. +/// +public static class SceneryCode +{ + public readonly record struct Token(string Text, int Index) + { + public int End => Index + Text.Length; + + public bool Is(string word) => string.Equals(Text, word, StringComparison.OrdinalIgnoreCase); + } + + private static readonly Regex Word = new(@"[^\s;]+"); + + /// + /// The text with every comment blanked out, line ends kept, so a place in one + /// is the same place in the other. + /// + /// Where a /* that never ends starts, or -1. + public static string Mask(string text, out int unclosed) + { + unclosed = -1; + char[]? masked = null; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '"') + { + // Quotes glue what they enclose into one word, comment marks and all. + int close = text.IndexOf('"', i + 1); + i = close < 0 ? text.Length : close; + continue; + } + + if (c != '/' || i + 1 >= text.Length) + continue; + + int end; + if (text[i + 1] == '/') + { + end = text.IndexOf('\n', i + 2); + if (end < 0) + end = text.Length; + } + else if (text[i + 1] == '*') + { + int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal); + if (close < 0) + unclosed = i; + end = close < 0 ? text.Length : close + 2; + } + else + { + continue; + } + + masked ??= text.ToCharArray(); + for (int j = i; j < end; j++) + if (masked[j] != '\n' && masked[j] != '\r') + masked[j] = ' '; + i = end - 1; + } + + return masked is null ? text : new string(masked); + } + + /// Text with its comments blanked out by . + public static List Tokens(string code) + { + var tokens = new List(); + for (var m = Word.Match(code); m.Success; m = m.NextMatch()) + tokens.Add(new Token(m.Value, m.Index)); + return tokens; + } +} diff --git a/StarterNG/Domain/Sceneries/SceneryFault.cs b/StarterNG/Domain/Sceneries/SceneryFault.cs new file mode 100644 index 0000000..2fd42ef --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryFault.cs @@ -0,0 +1,104 @@ +namespace StarterNG.Domain.Sceneries; + +public enum SceneryFaultKind +{ + /// /* never closed: the simulator skips the rest of the file. + UnclosedComment, + + /// A trainset with no endtrainset; the simulator ends it at the next one. + TrainsetNotClosed, + + /// + /// A trainset's header written twice: the simulator ends the first as an empty + /// trainset, noting it in its log, and reads the trainset under the second. + /// + TrainsetHeaderRepeated, + + /// The header lacks its name, track, offset or speed. + TrainsetIncomplete, + + StrayEndTrainset, + + /// reversed outside a trainset, or after its first vehicle. + ReversedMisplaced, + + /// A vehicle with no enddynamic, which swallows what follows. + VehicleNotClosed, + + VehicleIncomplete, + + /// + /// A vehicle with no load count: the simulator takes its enddynamic for + /// one and goes on to the next. + /// + LoadCountMissing, + + /// + /// A load count other than 0 with no type after it - the simulator takes the + /// enddynamic for the type; holds the count. + /// + LoadTypeMissing, + + /// + /// One word between the load and enddynamic: the simulator takes it for + /// a destination, the enddynamic for its name, and goes on to the next. + /// holds the word. + /// + WordBeforeEnd, + + /// A vehicle written as a trainset's is, with no trainset around it. + VehicleOutsideTrainset, + + /// + /// A trainset's track named with a space, which splits it into words; the + /// holds the name as written. + /// + TrackNameSplit, + + /// Text where a number belongs; holds it. + NotANumber, + + /// An include whose file is not there; names it. + MissingInclude, + + /// + /// An include whose file is there only under a name in other letter case: the + /// simulator looks for it in lower case, which on Linux misses it. The + /// holds the name on disk. + /// + IncludeLetterCase, + + /// An include with no end, which swallows what follows it. + IncludeNotClosed, + + /// A trainset the starter could not read, for a reason none of the above names. + TrainsetUnreadable, + + /// A vehicle outside a trainset the starter could not read. + VehicleUnreadable +} + +/// +/// A mistake in a scenery's text: where it is - the scenery's file or one it +/// includes, and the line, as an editor counts them - and what it concerns. +/// +/// The trainset or vehicle it concerns, when it has a name. +public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null, + string? Token = null) +{ + /// Where in the scenery's text it was found, to tell which trainset it falls in. + public int Offset { get; init; } + + /// + /// What the simulator does not read because of it - the vehicles, trainsets and + /// includes it takes as part of this entry - or null when it loses none. + /// + public string? Lost { get; init; } + + /// + /// The trainsets the simulator does not make because of it, their headers + /// swallowed by it or by what it set going - named, or placed when unnamed - + /// or null when there are none. + /// + public string? Unmade { get; init; } +} diff --git a/StarterNG/Domain/Sceneries/SceneryFaultText.cs b/StarterNG/Domain/Sceneries/SceneryFaultText.cs new file mode 100644 index 0000000..e29bc85 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryFaultText.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace StarterNG.Domain.Sceneries; + +/// A scenery's faults as they are read: where each one is, in a column, and what it is. +public static class SceneryFaultText +{ + /// Most it lists; the rest are only counted. + /// The wording for a key, in the language shown. + public static string Format(IReadOnlyList faults, int limit, Func text) + { + if (faults.Count == 0) + return ""; + + var shown = faults.Take(limit).Select(f => (Where: $"{f.File}:{f.Line}", Fault: f)).ToList(); + int width = shown.Max(f => f.Where.Length) + 2; + var lines = new StringBuilder(); + foreach (var (where, fault) in shown) + { + lines.Append(where.PadRight(width)).Append(Describe(fault, text)).Append('\n'); + // What it costs, under it: too long to follow on the same line. + if (fault.Lost is { } lost) + lines.Append(' ', width).Append(string.Format(text("SyntaxLost"), lost)).Append('\n'); + if (fault.Unmade is { } unmade) + lines.Append(' ', width).Append(string.Format(text("SyntaxUnmade"), unmade)).Append('\n'); + } + if (faults.Count > limit) + lines.Append(string.Format(text("SyntaxMore"), faults.Count - limit)); + return lines.ToString().TrimEnd(); + } + + private static string Describe(SceneryFault fault, Func text) => + string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? ""); +} diff --git a/StarterNG/Domain/Sceneries/SceneryIncludes.cs b/StarterNG/Domain/Sceneries/SceneryIncludes.cs new file mode 100644 index 0000000..08181b5 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryIncludes.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StarterNG.Domain.Sceneries; + +/// +/// The scenario's own files - included after FirstInit - put in place of their +/// include line when they hold trainsets, as the old starter did. Decorations +/// and road traffic live there; inlined, their consists are listed, can be +/// started from, and the copy written for a start carries them instead of the +/// include, so they are not loaded twice. +/// +/// +/// Only the plain include path end form: an include with parameters needs +/// them put in, and one whose end is not on its line is read by the +/// simulator in ways not worth guessing. One marked $optional stays too: +/// whether it is loaded is the player's to choose. Each inlined file is wrapped in +/// marker comments, which is how a trainset is told to belong to it. +/// +public static class SceneryIncludes +{ + public const string BeginMarker = "//$starter-include "; + public const string EndMarker = "//$starter-endinclude"; + + private static readonly Regex FirstInit = new(@"\bFirstInit\b", RegexOptions.IgnoreCase); + + private static readonly Regex Include = new( + @"^[ \t]*include[ \t]+(\S+)[ \t]+end\b[^\r\n]*", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + private static readonly Regex HasTrainset = new(@"^[ \t]*trainset\b", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + private static readonly Regex BlockComment = new(@"/\*[\s\S]*?\*/"); + + private static readonly Regex Marked = new( + Regex.Escape(BeginMarker) + @"(\S+)[\s\S]*?" + Regex.Escape(EndMarker)); + + /// + /// A file named as an include names it: its name as it is on disk, which the + /// marker carries, and its text - or null when there is none. + /// + public static string Inline(string text, Func read) + { + var firstInit = FirstInit.Match(text); + if (!firstInit.Success) + return text; + + string head = text[..firstInit.Index]; + string tail = text[firstInit.Index..]; + string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var comments = BlockComment.Matches(tail).Select(m => (m.Index, End: m.Index + m.Length)).ToList(); + + return head + Include.Replace(tail, m => + { + if (comments.Any(c => m.Index >= c.Index && m.Index < c.End)) + return m.Value; + + if (m.Value.Contains("$optional", StringComparison.OrdinalIgnoreCase)) + return m.Value; + + if (read(m.Groups[1].Value) is not var (file, content) || !HasTrainset.IsMatch(content)) + return m.Value; + + return BeginMarker + file + newline + content.TrimEnd() + newline + EndMarker; + }); + } + + /// The text without the inlined files: what the scenery itself says. + public static string WithoutInlined(string text) => Marked.Replace(text, ""); + + /// Where each inlined file sits in the text, by the name its include gave. + public static IReadOnlyList<(int Start, int End, string File)> Ranges(string text) => + Marked.Matches(text).Select(m => (m.Index, m.Index + m.Length, m.Groups[1].Value)).ToList(); + + public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) => + ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File; +} diff --git a/StarterNG/Domain/Sceneries/SceneryLines.cs b/StarterNG/Domain/Sceneries/SceneryLines.cs new file mode 100644 index 0000000..61a98e1 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryLines.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Domain.Sceneries; + +/// +/// Which file and line a place in a scenery's text is - its own file, or one put +/// in place of its include by , counted from that +/// file's own first line. +/// +public sealed class SceneryLines +{ + private readonly List _starts = new(); + private readonly List<(string File, int Line)> _lines = new(); + + public SceneryLines(string text, string file) + { + var outer = new Stack<(string File, int Line)>(); + string current = file; + int number = 1; + + for (int start = 0; ;) + { + int end = text.IndexOf('\n', start); + if (end < 0) + end = text.Length; + var line = text.AsSpan(start, end - start); + + _starts.Add(start); + if (line.StartsWith(SceneryIncludes.BeginMarker, StringComparison.Ordinal)) + { + // The marker stands where the include was; the file's lines follow. + _lines.Add((current, number)); + outer.Push((current, number)); + current = line[SceneryIncludes.BeginMarker.Length..].Trim().ToString(); + number = 1; + } + else if (line.StartsWith(SceneryIncludes.EndMarker, StringComparison.Ordinal) && outer.Count > 0) + { + (current, number) = outer.Pop(); + _lines.Add((current, number++)); + } + else + { + _lines.Add((current, number++)); + } + + if (end == text.Length) + break; + start = end + 1; + } + } + + public (string File, int Line) At(int index) + { + int found = _starts.BinarySearch(index); + return _lines[Math.Max(0, found >= 0 ? found : ~found - 1)]; + } + + /// A mistake at a place in the text, told by its file and line. + public SceneryFault Fault(SceneryFaultKind kind, int at, string? subject = null, string? token = null) + { + var (file, line) = At(at); + return new SceneryFault(kind, file, line, subject, token) { Offset = at }; + } +} diff --git a/StarterNG/Domain/Sceneries/ScenerySyntax.cs b/StarterNG/Domain/Sceneries/ScenerySyntax.cs new file mode 100644 index 0000000..a943d32 --- /dev/null +++ b/StarterNG/Domain/Sceneries/ScenerySyntax.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace StarterNG.Domain.Sceneries; + +/// +/// What the simulator makes of an include: whether it opens the file - and when +/// it does not, the file that is there under a name differing only in letter case. +/// +public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null); + +/// +/// Goes through a scenery's text for the parts the starter reads - trainsets, +/// vehicles, includes - finding where each trainset and loose vehicle is, and +/// what in them is written wrong. Whatever else the text holds is passed over. +/// +/// +/// The text is read twice. Once word by word as the simulator reads it, which +/// takes every field it expects whatever stands there, and after a vehicle +/// written wrong goes on to the next enddynamic - that finds the faults, +/// and what each makes the simulator lose. Once as the author meant it, each +/// entry ending where it was meant to - that finds the trainsets and vehicles, +/// so one the simulator would lose is still listed, and written right when +/// the starter writes it. +/// +public static class ScenerySyntax +{ + public sealed record Result( + IReadOnlyList<(int Start, int End)> Trainsets, + IReadOnlyList<(int Start, int End)> LooseVehicles, + IReadOnlyList Faults, + SceneryLines Lines); + + /// The scenery's text, its includes put in place. + /// The scenery's file name, for faults in its own text. + /// + /// What the simulator makes of an include, named as the include names it; null + /// leaves includes unchecked. + /// + public static Result Scan(string text, string file, Func? findInclude = null) + { + string code = SceneryCode.Mask(text, out int unclosed); + var tokens = SceneryCode.Tokens(code); + var lines = new SceneryLines(text, file); + + var simulator = new Reading(tokens, lines, findInclude, asSimulator: true); + simulator.Run(); + var author = new Reading(tokens, lines, null, asSimulator: false); + author.Run(); + + var faults = Unmade(simulator, author, lines); + if (unclosed >= 0) + faults.Insert(0, lines.Fault(SceneryFaultKind.UnclosedComment, unclosed)); + + return new Result(author.Trainsets.Select(t => (t.Start, t.End)).ToList(), author.Loose, faults, lines); + } + + /// + /// The simulator's faults, with those in a trainset whose header it swallowed + /// left out: they come of reading its vehicles with no trainset, and the + /// trainset is told of instead, with the fault it came of - the first of a + /// run of them, where the run started. + /// + private static List Unmade(Reading simulator, Reading author, SceneryLines lines) + { + var unmade = new List<(int Start, int End, int Cause)>(); + var lost = new Dictionary>(); + foreach (var (start, end, name) in author.Trainsets) + { + int cause = -1; + foreach (var (from, to, at) in simulator.Swallowed) + if (start >= from && start <= to) + { + cause = at; + break; + } + if (cause < 0) + continue; + foreach (var earlier in unmade) + if (cause >= earlier.Start && cause < earlier.End) + { + cause = earlier.Cause; + break; + } + unmade.Add((start, end, cause)); + + var (file, line) = lines.At(start); + bool named = name.Length > 0 && !name.Equals("none", StringComparison.OrdinalIgnoreCase); + if (!lost.TryGetValue(cause, out var names)) + lost[cause] = names = new List(); + names.Add(named ? name : $"{file}:{line}"); + } + + if (unmade.Count == 0) + return simulator.Faults; + + var faults = new List(); + foreach (var fault in simulator.Faults) + { + if (unmade.Any(u => fault.Offset >= u.Start && fault.Offset < u.End)) + continue; + faults.Add(fault.Lost is not null && lost.Remove(fault.Offset, out var names) + ? fault with { Unmade = Listed(names) } + : fault); + } + return faults; + } + + private static string Listed(List entries) + { + const int Named = 6; + string named = string.Join(", ", entries.Take(Named)); + return entries.Count > Named ? $"{named} … +{entries.Count - Named}" : named; + } + + private sealed class Reading( + List tokens, SceneryLines lines, Func? findInclude, bool asSimulator) + { + public readonly List<(int Start, int End, string Name)> Trainsets = new(); + public readonly List<(int, int)> Loose = new(); + public readonly List Faults = new(); + + /// The text each fault makes the simulator pass over - from, to - and where the fault is. + public readonly List<(int From, int To, int At)> Swallowed = new(); + + // The trainset being read: where it starts, its name, how many vehicles so + // far, and the word its header starts at. + private (int Index, string Name, int Vehicles, int At)? _open; + + public void Run() + { + for (int i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + if (token.Is("trainset")) + i = Header(i); + else if (token.Is("endtrainset")) + { + // One with no vehicles is no mistake: a place for the player's own + // consist, filled in the depot. + if (_open is { } ended) + Trainsets.Add((ended.Index, token.End, ended.Name)); + else + Fault(SceneryFaultKind.StrayEndTrainset, token.Index); + _open = null; + } + else if (token.Is("reversed")) + { + // Turns the trainset round only while none of its vehicles is placed yet. + if (_open is not { Vehicles: 0 }) + Fault(SceneryFaultKind.ReversedMisplaced, token.Index); + } + else if (token.Is("assignment") || token.Is("assignments")) + { + while (i + 1 < tokens.Count && !tokens[i].Is("endassignment")) + i++; + } + else if (token.Is("node")) + { + // Not a vehicle: only its kind is stepped over, what follows is its own. + if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic")) + i += 4; + else + i = Vehicle(i); + } + else if (token.Is("include")) + i = Include(i); + } + + if (_open is { } last) + NotClosed(last); + } + + // Unless its header is told incomplete already: the endtrainset taken for a field. + private void NotClosed((int Index, string Name, int Vehicles, int At) trainset) + { + if (!Faults.Any(f => f.Offset == trainset.Index)) + Fault(SceneryFaultKind.TrainsetNotClosed, trainset.Index, trainset.Name); + } + + // Each reader below takes the entry starting at the given word and gives + // back the last word it took. + + private int Header(int at) + { + var token = tokens[at]; + if (_open is { } unended) + { + // The same header twice over, the first with nothing under it: the + // simulator ends that one as empty - noting it in its log - and reads on. + if (unended.Vehicles == 0 && Same(unended.At, at, 4)) + Fault(SceneryFaultKind.TrainsetHeaderRepeated, unended.Index, unended.Name); + else + NotClosed(unended); + } + _open = null; + + if (!Fields(at, 4)) + { + // Still opened, so its endtrainset ends it rather than standing alone. + int given = 0; + while (given < 4 && at + given + 1 < tokens.Count && !Keyword(tokens[at + given + 1])) + given++; + string? partial = given > 0 ? tokens[at + 1].Text : null; + _open = (token.Index, partial ?? "", 0, at); + if (!asSimulator) + return at + given; + + // The simulator takes four words, whatever they are. + int taken = Math.Min(at + 4, tokens.Count - 1); + Fault(SceneryFaultKind.TrainsetIncomplete, token.Index, partial, swallowed: (at + given, taken)); + return taken; + } + + string name = tokens[at + 1].Text; + if (SplitTrack(tokens, at) is { } words) + { + // Its offset is the rest of the track's name - a lost letter, often. + string track = string.Join(' ', tokens.Skip(at + 2).Take(words).Select(t => t.Text)); + Fault(SceneryFaultKind.TrackNameSplit, tokens[at + 2].Index, name, track); + } + else + { + Number(tokens[at + 3], name); + Number(tokens[at + 4], name); + } + _open = (token.Index, name, 0, at); + return at + 4; + } + + private int Vehicle(int at) + { + bool inTrainset = _open is not null; + // node range range name dynamic, then folder skin mmd [track] offset driver [coupling | speed] + int fields = inTrainset ? 10 : 11; + string name = tokens[at + 3].Text; + bool complete = Fields(at, fields); + + // Where the author meant it to end: its enddynamic, or with that missing + // the word before whatever comes next. + int meant = at + 5; + for (; meant < tokens.Count && !Keyword(tokens[meant]); meant++) + if (tokens[meant].Is("enddynamic")) + break; + bool closed = meant < tokens.Count && tokens[meant].Is("enddynamic"); + if (!closed) + meant--; + + if (!asSimulator) + return ListVehicle(at, complete, closed, meant); + + if (at + fields + 1 >= tokens.Count) + { + Fault(complete ? SceneryFaultKind.VehicleNotClosed : SceneryFaultKind.VehicleIncomplete, + tokens[at].Index, name); + return tokens.Count - 1; + } + + var (fault, trackless) = CheckFields(at, complete, inTrainset, name); + if (fault is null && !closed) + fault = SceneryFaultKind.VehicleNotClosed; + + int end = LoadEnd(at + fields + 1, trackless, meant, ref fault, out string? faulty); + + if (fault is { } kind) + Fault(kind, tokens[at].Index, name, faulty, end > meant ? (meant, end) : null); + + if (_open is { } open) + _open = open with { Vehicles = open.Vehicles + 1 }; + return end; + } + + // As the author meant it: there to be listed, whatever is wrong with it. + private int ListVehicle(int at, bool complete, bool closed, int meant) + { + // Without even the fields every vehicle has it is no vehicle to list. + if (!complete) + return at; + // One with its end missing still counts in its trainset: the starter + // reads it all the same, and writes it whole for a start. One written + // for a trainset with none around it is no loose vehicle either. + if (_open is { } current) + _open = current with { Vehicles = current.Vehicles + 1 }; + else if (closed && !TrainsetStyle(at)) + Loose.Add((tokens[at].Index, tokens[meant].End)); + return meant; + } + + // What is written wrong in a vehicle's fields, the numbers told of right away. + private (SceneryFaultKind? Fault, bool Trackless) CheckFields(int at, bool complete, bool inTrainset, string name) + { + SceneryFaultKind? fault = null; + // A track named by a number is none the simulator has: it drops the vehicle + // right after its load, going to the next enddynamic. + bool trackless = false; + if (!complete) + fault = SceneryFaultKind.VehicleIncomplete; + else + { + Number(tokens[at + 1], name); + Number(tokens[at + 2], name); + if (inTrainset) + { + Number(tokens[at + 8], name); + string coupling = tokens[at + 10].Text; + int dot = coupling.IndexOf('.'); + string flags = dot < 0 ? coupling : coupling[..dot]; + if (flags.Length > 0 && !int.TryParse(flags, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + Fault(SceneryFaultKind.NotANumber, tokens[at + 10].Index, name, coupling); + } + else if (TrainsetStyle(at)) + { + // Its header missing or commented out. + fault = SceneryFaultKind.VehicleOutsideTrainset; + trackless = true; + } + else + { + Number(tokens[at + 9], name); + Number(tokens[at + 11], name); + trackless = IsNumber(tokens[at + 8]); + } + } + return (fault, trackless); + } + + // The last word of a vehicle, from its load count on, with what is written wrong + // there unless something before it already was. + private int LoadEnd(int next, bool trackless, int meant, ref SceneryFaultKind? fault, out string? faulty) + { + faulty = null; + + // Then the load: its count, and only after a count other than 0 its type. + var count = tokens[next++]; + if (Counted(count.Text)) + { + if (next < tokens.Count && tokens[next].Is("enddynamic") && fault is null) + (fault, faulty) = (SceneryFaultKind.LoadTypeMissing, count.Text); + next++; + } + else if (count.Is("enddynamic")) + fault ??= SceneryFaultKind.LoadCountMissing; + + // Then enddynamic - or a destination, one word more, and on to the next enddynamic. + int end; + if (trackless) + end = EndDynamic(next); + else if (next < tokens.Count && tokens[next].Is("enddynamic")) + end = next; + else + { + end = EndDynamic(next + 2); + if (fault is null && end > meant) + (fault, faulty) = (SceneryFaultKind.WordBeforeEnd, tokens[next].Text); + } + return end; + } + + private int Include(int at) + { + var token = tokens[at]; + string name = at + 1 < tokens.Count ? tokens[at + 1].Text : ""; + + if (!asSimulator) + { + // Its parameters run to "end". Another include, a trainset or a node on + // the way means the end is missing - read on as what they are. + int stop = at + 2; + while (stop < tokens.Count && !tokens[stop].Is("end") && !Keyword(tokens[stop]) && + !tokens[stop].Is("include") && !tokens[stop].Is("firstinit")) + stop++; + return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1; + } + + if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup) + { + if (lookup.OnDisk is { } onDisk) + Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name); + else + Fault(SceneryFaultKind.MissingInclude, token.Index, token: name); + } + + // The simulator takes its parameters up to the word "end", written just so, + // whatever comes before it. + int end = at + 2; + while (end < tokens.Count && tokens[end].Text != "end") + end++; + int last = Math.Min(end, tokens.Count - 1); + if (Lost(at + 1, last) is not null) + Fault(SceneryFaultKind.IncludeNotClosed, token.Index, token: name, swallowed: (at + 1, last)); + return last; + } + + // A vehicle with no trainset around it, written as within one - offset, crew, + // coupling - where its track, offset and crew belong. + private bool TrainsetStyle(int at) => + at + 9 < tokens.Count && IsNumber(tokens[at + 8]) && Driver(tokens[at + 9]); + + // The first enddynamic from the given word on, or the last word there is. + private int EndDynamic(int from) + { + int at = from; + while (at < tokens.Count && !tokens[at].Is("enddynamic")) + at++; + return Math.Min(at, tokens.Count - 1); + } + + /// + /// The entries starting past up to , + /// as they would be named in the text - vehicles by their names - or null + /// when none start there. + /// + private string? Lost(int after, int last) + { + var entries = new List(); + for (int at = after + 1; at <= last && at < tokens.Count; at++) + { + var token = tokens[at]; + if (token.Is("node") && at + 4 < tokens.Count) + entries.Add(tokens[at + 4].Is("dynamic") + ? tokens[at + 3].Text + : $"{tokens[at + 4].Text} {tokens[at + 3].Text}"); + else if ((token.Is("trainset") || token.Is("include")) && at + 1 < tokens.Count) + entries.Add($"{token.Text} {tokens[at + 1].Text}"); + else if (token.Is("endtrainset") || token.Is("firstinit")) + entries.Add(token.Text); + } + + return entries.Count == 0 ? null : Listed(entries); + } + + // Whether the words after the two given are the same, so many of them. + private bool Same(int first, int second, int count) + { + for (int i = 1; i <= count; i++) + if (second + i >= tokens.Count || !tokens[first + i].Text.Equals(tokens[second + i].Text, StringComparison.OrdinalIgnoreCase)) + return false; + return true; + } + + /// Whether the words after the one at are all there and none starts something else. + private bool Fields(int at, int count) + { + if (at + count >= tokens.Count) + return false; + for (int i = at + 1; i <= at + count; i++) + if (Keyword(tokens[i]) || tokens[i].Is("enddynamic")) + return false; + return true; + } + + private void Number(SceneryCode.Token field, string subject) + { + if (!IsNumber(field)) + Fault(SceneryFaultKind.NotANumber, field.Index, subject, field.Text); + } + + /// The words the simulator takes along with it, past the one given up to the last. + private void Fault(SceneryFaultKind kind, int index, string? subject = null, string? token = null, + (int After, int Last)? swallowed = null) + { + // Faults are the simulator's reading's to tell. + if (!asSimulator) + return; + string? lost = null; + if (swallowed is var (after, last) && Lost(after, last) is { } entries) + { + lost = entries; + Swallowed.Add((tokens[after + 1].Index, tokens[last].Index, index)); + } + Faults.Add(lines.Fault(kind, index, subject, token) with { Lost = lost }); + } + } + + /// + /// How many words a trainset's track name was split into by spaces, judged by + /// the two numbers - offset and speed - that follow them; null when it was not. + /// + private static int? SplitTrack(List tokens, int header) + { + if (IsNumber(tokens[header + 3])) + return null; + + for (int words = 2; words <= 4; words++) + { + int offset = header + 2 + words; + if (offset + 1 >= tokens.Count || Keyword(tokens[offset - 1]) || IsNumber(tokens[offset - 1])) + return null; + if (IsNumber(tokens[offset]) && IsNumber(tokens[offset + 1])) + return words; + } + + return null; + } + + /// + /// Whether a load count reads as other than 0, the way the simulator reads a + /// whole number: the digits it starts with, and 0 for one that does not. + /// + private static bool Counted(string text) + { + int at = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0; + bool any = false; + for (; at < text.Length && char.IsAsciiDigit(text[at]); at++) + any |= text[at] != '0'; + return any; + } + + private static bool Driver(SceneryCode.Token token) => + token.Is("headdriver") || token.Is("reardriver") || token.Is("passenger") || token.Is("nobody"); + + private static bool IsNumber(SceneryCode.Token token) => + double.TryParse(token.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out _); + + private static bool Keyword(SceneryCode.Token token) => + token.Is("node") || token.Is("trainset") || token.Is("endtrainset"); + + /// + /// An include named outright - not one built from a template's parameters or + /// picked at random, which only the simulator can tell. + /// + private static bool Plain(string name) => + name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) && + name.IndexOfAny(new[] { '*', '?' }) < 0; +} diff --git a/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs new file mode 100644 index 0000000..aaadef6 --- /dev/null +++ b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Classes; + +namespace StarterNG.Domain.Sceneries; + +/// +/// A trainset written back into its own scenery file. Only the vehicles that +/// changed are the starter's to rewrite: everything else the author put there - +/// the header as typed, the description, comments, an assignments block, the other +/// vehicles as they are spelled - stays as it was, and the rest of the file is not +/// touched at all. +/// +public static class SceneryTrainsetText +{ + private static readonly Regex LoneLineFeed = new(@"(? + /// The trainset as the file should now hold it, in the file's own line ends. + /// Ends at endtrainset, as the parser's match of the old one did. + /// + /// + /// What stands between the vehicles stays in its place among those that stay. + /// A comment inside a vehicle's own node goes when that node is rewritten or + /// the vehicle removed: there is no longer a place for it. + /// + public static string Compose(Trainset trainset, string newline) + { + string raw = trainset.RawEntry; + var saved = new Trainset(raw); + + // Not read to its end: what stands there is not known, so it all stays. + if (!saved.Parsed) + return raw; + + var (speed, vehicles, end) = saved.Layout; + + // Line ends of both kinds are left as they are: there is no one kind to give them. + bool mixed = raw.Contains("\r\n", StringComparison.Ordinal) && LoneLineFeed.IsMatch(raw); + string Own(string text) => mixed ? text : Normalize(text).Replace("\n", newline); + + int first = vehicles.Count > 0 ? vehicles[0].Start : end; + string head = raw[..first]; + + // The speed is the one header field the depot changes - the ready switch. + // Rewritten only when it differs, so "0.0" stays "0.0". + if (saved.Velocity != trainset.Velocity && speed is { } field) + head = raw[..field.Index] + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + raw[field.End..first]; + + string[] was = saved.Vehicles.Select(v => v.ToTrainsetNode()).ToArray(); + string[] now = trainset.Vehicles.Select(v => v.ToTrainsetNode()).ToArray(); + string ending = end < raw.Length ? raw[end..] : "endtrainset"; + if (was.SequenceEqual(now)) + return Own(head + raw[first..end] + ending); + + string Written(int i) => raw[vehicles[i].Start..vehicles[i].End]; + string After(int i) => raw[vehicles[i].End..(i + 1 < vehicles.Count ? vehicles[i + 1].Start : end)]; + + var sb = new StringBuilder(Own(head)); + + void AtLineStart() + { + if (sb.Length > 0 && sb[^1] != '\n') + sb.Append(newline); + } + + // One moved here is still written as it was. + void Rewrite(int j) + { + AtLineStart(); + int written = Array.IndexOf(was, now[j]); + sb.Append(written >= 0 ? Own(Written(written)) : now[j].TrimEnd('\n')); + } + + int[,] staying = Staying(was, now); + for (int i = 0, j = 0; i < was.Length || j < now.Length;) + { + bool both = i < was.Length && j < now.Length; + if (both && was[i] == now[j]) + { + sb.Append(Own(Written(i) + After(i))); + i++; + j++; + } + else if (both && staying[i + 1, j + 1] == staying[i, j]) + { + // In the place of the one there before, so what followed that one stays. + Rewrite(j++); + sb.Append(Own(After(i++))); + } + else if (j < now.Length && (i == was.Length || staying[i, j + 1] == staying[i, j])) + { + Rewrite(j++); + sb.Append(newline); + } + else + { + // Gone, the end of its line with it; what followed it stays. + string after = After(i++); + if (!string.IsNullOrWhiteSpace(after)) + { + AtLineStart(); + sb.Append(Own(RestOfLine.Replace(after, "", 1))); + } + } + } + + AtLineStart(); + return sb.Append(Own(ending)).ToString(); + } + + /// + /// How many vehicles can stay in the order they are in, of those from each place on + /// in the old list and in the new one: the longest common run of the two. + /// + private static int[,] Staying(string[] was, string[] now) + { + var staying = new int[was.Length + 1, now.Length + 1]; + for (int i = was.Length - 1; i >= 0; i--) + for (int j = now.Length - 1; j >= 0; j--) + staying[i, j] = was[i] == now[j] + ? staying[i + 1, j + 1] + 1 + : Math.Max(staying[i + 1, j], staying[i, j + 1]); + return staying; + } + + /// + /// The file with the trainset's old text swapped for , + /// or null when that text cannot be told apart in the file: missing, or found + /// more often than the scenery has trainsets written that way. + /// + public static string? Replace(string fileText, Trainset trainset, IReadOnlyList sceneryTrainsets, + string entry) + { + var twins = sceneryTrainsets.Where(t => t.RawEntry == trainset.RawEntry).ToList(); + int ordinal = twins.FindIndex(t => ReferenceEquals(t, trainset)); + if (ordinal < 0 || trainset.RawEntry.Length == 0) + return null; + + var found = new List(); + for (int at = fileText.IndexOf(trainset.RawEntry, StringComparison.Ordinal); + at >= 0; + at = fileText.IndexOf(trainset.RawEntry, at + trainset.RawEntry.Length, StringComparison.Ordinal)) + found.Add(at); + + if (found.Count != twins.Count) + return null; + + int start = found[ordinal]; + return fileText[..start] + entry + fileText[(start + trainset.RawEntry.Length)..]; + } + + /// + /// Whether the trainset now differs from what its file holds, compared the way + /// both would be written so a different spelling of the same number is no change. + /// + public static bool IsModified(Trainset trainset) => + trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry); + + private static readonly Dictionary SavedSignatures = new(); + + private static string SavedSignature(string raw) + { + lock (SavedSignatures) + { + if (!SavedSignatures.TryGetValue(raw, out string? signature)) + SavedSignatures[raw] = signature = Signature(new Trainset(raw)); + return signature; + } + } + + private static string Signature(Trainset trainset) => + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" + + Dynamic.ToTrainsetNodes(trainset.Vehicles); + + private static string Normalize(string text) => text.Replace("\r\n", "\n"); +} diff --git a/StarterNG/Domain/Sceneries/SceneryWeather.cs b/StarterNG/Domain/Sceneries/SceneryWeather.cs index ebe9b78..03c7438 100644 --- a/StarterNG/Domain/Sceneries/SceneryWeather.cs +++ b/StarterNG/Domain/Sceneries/SceneryWeather.cs @@ -69,6 +69,23 @@ public sealed class SceneryWeather Dirty = true; } + /// What was set by hand stays over a file read anew; untouched, the file's own stands. + public void KeepChoicesOf(SceneryWeather earlier) + { + if (!earlier.Changed) + return; + + Time = earlier.Time; + ScenarioTimeOverride = earlier.ScenarioTimeOverride; + Day = earlier.Day; + Temperature = earlier.Temperature; + FogStart = earlier.FogStart; + FogEnd = earlier.FogEnd; + Overcast = earlier.Overcast; + OvercastRandom = earlier.OvercastRandom; + Dirty = earlier.Dirty; + } + public void SetFogRange(int start, int end, IRandomSource random) { FogStart = Math.Clamp(start, FogMin, FogMax); diff --git a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs index bf4270d..f77d328 100644 --- a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs +++ b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs @@ -1,5 +1,5 @@ -using System; using System.Globalization; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using StarterNG.Application.Abstractions; @@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries; public static class SceneryWeatherWriter { - private static readonly Regex ManagedConfigLine = new( - @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + // Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo. + private const string WordStart = @"(?What the written block takes the place of - looked for in the code alone, never in a comment. + private static readonly Regex[] Replaced = + { + ConfigBlock, + new($@"(?is){WordStart}time\s+\d[^\r\n]*?{WordStart}endtime{WordEnd}"), + new($@"(?is){WordStart}atmo{WordEnd}.*?{WordStart}endatmo{WordEnd}"), + new(@"(?im)^[ \t]*movelight\s+\S+"), + new($@"(?i){WordStart}scenario\.weather\.temperature\s+\S+"), + new($@"(?i){WordStart}scenario\.time\.override\s+\S+") + }; public static string Rewrite(string text, SceneryWeather weather, IRandomSource random) { @@ -19,12 +36,8 @@ public static class SceneryWeatherWriter 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+", " "); + foreach (var pattern in Replaced) + stripped = Cut(stripped, pattern); var inv = CultureInfo.InvariantCulture; string config = @@ -46,6 +59,38 @@ public static class SceneryWeatherWriter } } + /// + /// The text without what the pattern finds in its code. A comment inside what + /// is taken out stays where it is, line ends and all, for a // to end + /// where it did. + /// + private static string Cut(string text, Regex pattern) + { + string code = SceneryCode.Mask(text, out _); + var sb = new StringBuilder(text.Length); + int at = 0; + + foreach (Match found in pattern.Matches(code)) + { + sb.Append(text, at, found.Index - at); + string written = text.Substring(found.Index, found.Length); + if (written == found.Value) + { + sb.Append(' '); + } + else + { + var comments = new StringBuilder(written); + foreach (var word in Enumerable.Reverse(SceneryCode.Tokens(found.Value))) + comments.Remove(word.Index, word.Text.Length); + sb.Append(comments); + } + at = found.Index + found.Length; + } + + return sb.Append(text, at, text.Length - at).ToString(); + } + private static string DelphiHourMinute(string hhmm) { int colon = hhmm.IndexOf(':'); @@ -57,20 +102,17 @@ public static class SceneryWeatherWriter : "0" + hhmm[colon..]; } + /// The settings of every config block but those the starter writes itself. 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"); - } + foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _))) + foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n')) + { + string line = raw.Trim(); + if (line.Length > 0) + sb.Append(line).Append("\r\n"); + } return sb.ToString(); } } diff --git a/StarterNG/Domain/SimulatorNames.cs b/StarterNG/Domain/SimulatorNames.cs new file mode 100644 index 0000000..602646f --- /dev/null +++ b/StarterNG/Domain/SimulatorNames.cs @@ -0,0 +1,18 @@ +namespace StarterNG.Domain; + +/// +/// File names as the simulator makes them: its parser folds every word it reads +/// to lower case - ASCII letters only - the files a scenery includes and the +/// scenario given with -s among them, and opens the file by that name. +/// Where letter case counts, as on Linux, a file is found only when it is named +/// so on disk. +/// +public static class SimulatorNames +{ + public static string Lower(string name) => + string.Create(name.Length, name, static (folded, source) => + { + for (int i = 0; i < source.Length; i++) + folded[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + ('a' - 'A')) : source[i]; + }); +} diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs index 788062a..e7d3bac 100644 --- a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs +++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs @@ -5,7 +5,6 @@ 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; @@ -21,58 +20,97 @@ public sealed class SceneryParser _random = random; } - public Scenery Parse(string path, string content) + /// + /// What the simulator makes of an include, as the include names it; null leaves + /// includes unchecked. + /// + public Scenery Parse(string path, string content, Func? findInclude = null) { string original = content; - var faults = new List(); - var attachments = ParseAttachments(original); + // A file put in place of its include has directives of its own, for itself. + string own = SceneryIncludes.WithoutInlined(original); + + var attachments = ParseAttachments(own); var includes = new List(); content = StripOptionalIncludes(content, includes); - 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); + // Read from the text with its optional includes blanked, which keeps every + // place where it was, so a fault's line is the line in the file. + var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude); + var faults = syntax.Faults.ToList(); + var inlined = SceneryIncludes.Ranges(content); + var trainsets = new List(); var looseVehicles = new List(); var looseNames = new List(); - content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults); + var cuts = new List<(int Start, int End, string With)>(); - var scenery = new Scenery(path, content) + foreach (var (start, end) in syntax.Trainsets) { - Group = MatchDirective(original, "l"), - Name = MatchDirective(original, "n"), - Description = MatchAllDirectives(original, "d"), - ImageName = MatchDirective(original, "i"), - Archival = MatchDirective(original, "a") != null + var trainset = new Trainset(content[start..end]) { SourceFile = SceneryIncludes.FileAt(inlined, start) }; + if (!trainset.Parsed && !faults.Any(f => f.Offset >= start && f.Offset < end)) + faults.Add(syntax.Lines.Fault(SceneryFaultKind.TrainsetUnreadable, start, trainset.Name)); + cuts.Add((start, end, $"{{{{{trainsets.Count}}}}}")); + trainsets.Add(trainset); + } + + foreach (var (start, end) in syntax.LooseVehicles) + { + // Included files' loose vehicles - road traffic, mostly - stay where they + // are as written, only their names are noted. + var vehicle = ReadLooseVehicle(content[start..end]); + looseNames.Add(vehicle?.Name ?? LooseName(content[start..end])); + if (SceneryIncludes.FileAt(inlined, start) != null) + continue; + + if (vehicle is null) + { + if (!faults.Any(f => f.Offset >= start && f.Offset < end)) + faults.Add(syntax.Lines.Fault(SceneryFaultKind.VehicleUnreadable, start, LooseName(content[start..end]))); + continue; + } + + looseVehicles.Add(vehicle); + cuts.Add((start, end, "\n")); + } + + var scenery = new Scenery(path, Cut(content, cuts)) + { + Group = MatchDirective(own, "l"), + Name = MatchDirective(own, "n"), + Description = MatchAllDirectives(own, "d"), + ImageName = MatchDirective(own, "i"), + Archival = MatchDirective(own, "a") != null }; scenery.Attachments.AddRange(attachments); scenery.Includes.AddRange(includes); + scenery.Trainsets.AddRange(trainsets); scenery.LooseVehicles.AddRange(looseVehicles); scenery.LooseVehicleNames.AddRange(looseNames); - scenery.Faults.AddRange(faults); + scenery.Faults.AddRange(faults.OrderBy(f => f.Offset)); - ParseWeather(original, scenery.Weather); + // Without its comments: what is read here is what the writer later puts down as active. + ParseWeather(SceneryCode.Mask(original, out _), 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 string Cut(string text, List<(int Start, int End, string With)> cuts) + { + var sb = new System.Text.StringBuilder(text.Length); + int at = 0; + foreach (var (start, end, with) in cuts.OrderBy(c => c.Start)) + { + sb.Append(text, at, start - at).Append(with); + at = end; + } + return sb.Append(text, at, text.Length - at).ToString(); + } + private static List ParseAttachments(string content) { var attachments = new List(); @@ -115,7 +153,7 @@ public sealed class SceneryParser return Regex.Replace( content, - @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b", + @"include[ \t]+(\S+(?:[ \t]+\S+)*?)[ \t]+end[ \t]*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b", m => { string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " "); @@ -137,65 +175,55 @@ public sealed class SceneryParser Kind = kind, Selected = kind == 1 }); - return " "; + return Regex.Replace(m.Value, @"[^\r\n]", " "); }, RegexOptions.IgnoreCase); } - private static string ExtractLooseVehicles(string content, List vehicles, List names, - List faults) + private static readonly Regex LooseNode = new( + @"(?is)^node\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic\b)\S+)*)\s*enddynamic$"); + + private static string LooseName(string node) => + SceneryCode.Tokens(SceneryCode.Mask(node, out _)).ElementAtOrDefault(3).Text ?? ""; + + /// A vehicle standing on its own, or null when its node cannot be read. + private static Dynamic? ReadLooseVehicle(string node) { var inv = CultureInfo.InvariantCulture; + var m = LooseNode.Match(SceneryCode.Mask(node, out _)); + if (!m.Success) + return null; - 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 => + try + { + var vehicle = new Dynamic { - string name = m.Groups[3].Value; + RangeMax = float.Parse(m.Groups[1].Value, inv), + RangeMin = float.Parse(m.Groups[2].Value, inv), + Name = m.Groups[3].Value, + 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 = Dynamic.ParseDriver(m.Groups[9].Value), + HasVelocity = true, + Velocity = float.Parse(m.Groups[10].Value, inv) + }; - 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 - { - faults.Add($"# loose dynamic parse fault: {name}"); - names.Add(name); - return m.Value; - } - }); + vehicle.ReadTrailing(m.Groups[11].Value + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .ToList()); + return vehicle; + } + catch (FormatException) + { + return null; + } + catch (OverflowException) + { + return null; + } } private void ParseWeather(string content, SceneryWeather weather) diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs index cbbde38..e83d8f7 100644 --- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs +++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs @@ -3,10 +3,10 @@ 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; using StarterNG.Domain.Sceneries; using StarterNG.Infrastructure.Adapters; @@ -21,6 +21,9 @@ public sealed class SceneryRepository : ISceneryRepository private readonly IDiagnosticsLog _log; private readonly SceneryParser _parser; + /// The scenery folders' files and folders, listed once per load of them all. + private readonly ConcurrentDictionary<(string, bool), IReadOnlyList> _listings = new(); + public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser) { _files = files; @@ -31,6 +34,7 @@ public sealed class SceneryRepository : ISceneryRepository public IReadOnlyList LoadAll(IProgress? progress = null) { + _listings.Clear(); var files = SceneryFiles().ToList(); var loaded = new ConcurrentBag(); int total = Math.Max(1, files.Count); @@ -40,7 +44,7 @@ public sealed class SceneryRepository : ISceneryRepository new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, file => { - var scenery = Load(file); + var scenery = LoadOne(file); if (scenery is not null) loaded.Add(scenery); @@ -52,14 +56,44 @@ public sealed class SceneryRepository : ISceneryRepository } public Scenery? Load(string path) + { + _listings.Clear(); + return LoadOne(path); + } + + public IReadOnlyList? ListFiles() => + _files.DirectoryExists(_paths.Scenery) ? SceneryFiles().ToList() : null; + + private Scenery? LoadOne(string path) { try { if (!_files.FileExists(path)) return null; - var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path))); - scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt")); + // Looked at ahead of the reading: changed meanwhile, it counts as changed. + var sources = new Dictionary { [path] = FileStamp.Of(_files, path) }; + + // With its own .sbt the scenery's terrain comes from it, and the simulator + // passes its terrain includes over without opening them. + bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt"); + IncludeLookup FindInclude(string name) => + Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm")) + ? new IncludeLookup(true) + : new IncludeLookup(false, ResolveInclude(name)); + + (string File, string Text)? ReadInclude(string name) + { + string? file = ResolveInclude(name); + string full = Path.Combine(_paths.Scenery, file ?? name.Replace('\\', '/')); + sources[full] = FileStamp.Of(_files, full); + return file is null ? null : (file, LegacyText.Decode(_files.ReadAllBytes(full))); + } + + string text = SceneryIncludes.Inline(LegacyText.Decode(_files.ReadAllBytes(path)), ReadInclude); + var scenery = _parser.Parse(path, text, FindInclude); + scenery.HasCompanionTimetable = binaryTerrain; + scenery.Sources = sources; return scenery; } catch (Exception ex) @@ -69,6 +103,58 @@ public sealed class SceneryRepository : ISceneryRepository } } + /// + /// Whether the simulator opens a file of the scenery folder named so: by the + /// name in lower case, which on Linux has to be how it is named on disk. + /// + private bool Opens(string name) => + _files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/')))); + + /// + /// An include's file as it is named on disk, from the scenery folder, whatever + /// the case of its letters - as Windows, or Wine, finds it: whose consists the + /// copy written for a start carries in place of the include, so they come along + /// on Linux too. + /// + private string? ResolveInclude(string name) + { + string relative = name.Replace('\\', '/'); + if (_files.FileExists(Path.Combine(_paths.Scenery, relative))) + return relative; + + string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + string directory = _paths.Scenery; + var found = new List(); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i] is "." or "..") + { + directory = Path.Combine(directory, parts[i]); + found.Add(parts[i]); + continue; + } + + bool last = i == parts.Length - 1; + string? match = Listing(directory, last) + .FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), parts[i], + StringComparison.OrdinalIgnoreCase)); + if (match is null) + return null; + directory = match; + found.Add(Path.GetFileName(match)); + } + + return string.Join('/', found); + } + + private IReadOnlyList Listing(string directory, bool files) => + _listings.GetOrAdd((directory, files), key => + { + if (!_files.DirectoryExists(key.Item1)) + return Array.Empty(); + return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1); + }); + private IEnumerable SceneryFiles() => _files.GetFiles(_paths.Scenery, "*.scn") .Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix); diff --git a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs index 75496d4..d309b47 100644 --- a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs +++ b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs @@ -1,8 +1,10 @@ using System; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using StarterNG.Application.Abstractions; using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Adapters; namespace StarterNG.Infrastructure.Sceneries; @@ -10,6 +12,10 @@ public sealed class TimetableLocator { private const string NoTimetable = "none"; + // The heading of a timetable: [ Rodzaj i numer pociagu | EIE1531 ] + private static readonly Regex NumberLine = new(@"^\s*\[\s*Rodzaj\b[^|\r\n]*\|([^\]\r\n]+)", + RegexOptions.Multiline | RegexOptions.IgnoreCase); + private readonly IFileSystem _files; public TimetableLocator(IFileSystem files) @@ -36,4 +42,22 @@ public sealed class TimetableLocator return candidates.FirstOrDefault(_files.FileExists); } + + /// The number the timetable gives its train; null with no timetable or no such heading in it. + public string? TrainNumber(Scenery scenery, string? name) + { + if (Resolve(scenery, name) is not { } path) + return null; + + try + { + var heading = NumberLine.Match(LegacyText.Decode(_files.ReadAllBytes(path))); + string number = heading.Groups[1].Value.Trim(); + return number.Length > 0 ? number : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } } diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index f4f52fd..18b547e 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -353,7 +353,7 @@ public partial class Scenarios : UserControl private void PopulateFaults(Scenery scenery) { var lines = new List(); - lines.AddRange(scenery.Faults); + lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key])); var db = AppServices.Current.Library.Vehicles; foreach (var ts in scenery.Trainsets)