mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
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.
This commit is contained in:
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Infrastructure.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
/// <summary>The scenery reader over a fake clock and dice, for tests of what it makes of a text.</summary>
|
||||||
|
public static class TestScenery
|
||||||
|
{
|
||||||
|
/// <param name="now">The hour a scenery that names none starts at.</param>
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -72,4 +72,45 @@ public class GameLibraryTests
|
|||||||
|
|
||||||
Assert.Contains("textures.txt", installation.Log.Text);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using StarterNG.Infrastructure.Sceneries;
|
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryExportTests
|
public class SceneryExportTests
|
||||||
{
|
{
|
||||||
private static SceneryParser Parser() =>
|
|
||||||
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
||||||
{
|
{
|
||||||
@@ -18,7 +14,7 @@ public class SceneryExportTests
|
|||||||
endtrainset
|
endtrainset
|
||||||
FirstInit
|
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());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
@@ -27,6 +23,45 @@ public class SceneryExportTests
|
|||||||
Assert.Contains("endtrainset", exported);
|
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]
|
[Fact]
|
||||||
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
||||||
{
|
{
|
||||||
@@ -36,7 +71,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
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());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
@@ -54,7 +89,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
scenery.Includes[0].Selected = false;
|
scenery.Includes[0].Selected = false;
|
||||||
|
|
||||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
@@ -71,7 +106,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
scenery.HasCompanionTimetable = false;
|
scenery.HasCompanionTimetable = false;
|
||||||
Assert.Contains("include rozklad.inc",
|
Assert.Contains("include rozklad.inc",
|
||||||
@@ -95,7 +130,7 @@ public class SceneryExportTests
|
|||||||
time 8:30 0 0 endtime
|
time 8:30 0 0 endtime
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
scenery.Weather.Day = 200;
|
scenery.Weather.Day = 200;
|
||||||
scenery.Weather.Temperature = 25;
|
scenery.Weather.Temperature = 25;
|
||||||
@@ -120,7 +155,7 @@ public class SceneryExportTests
|
|||||||
endtrainset
|
endtrainset
|
||||||
FirstInit
|
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.True(scenery.Trainsets[0].Decor);
|
||||||
Assert.DoesNotContain("trainset ai_1",
|
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
|
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||||
FirstInit
|
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());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
Assert.Contains("wagon_1", exported);
|
Assert.Contains("wagon_1", exported);
|
||||||
Assert.Contains("enddynamic", 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
using StarterNG.Infrastructure.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryParserTests
|
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]
|
[Fact]
|
||||||
public void Directives_become_the_scenery_headline()
|
public void Directives_become_the_scenery_headline()
|
||||||
{
|
{
|
||||||
@@ -19,7 +16,7 @@ public class SceneryParserTests
|
|||||||
//$i images/kato.jpg
|
//$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("Śląsk", scenery.Group);
|
||||||
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
||||||
@@ -32,7 +29,7 @@ public class SceneryParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void The_a_directive_marks_a_scenery_archival()
|
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);
|
Assert.True(scenery.Archival);
|
||||||
}
|
}
|
||||||
@@ -46,7 +43,7 @@ public class SceneryParserTests
|
|||||||
//$f doc/notes.txt
|
//$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,
|
Assert.Collection(scenery.Attachments,
|
||||||
first =>
|
first =>
|
||||||
@@ -76,7 +73,7 @@ public class SceneryParserTests
|
|||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var include = Assert.Single(scenery.Includes);
|
var include = Assert.Single(scenery.Includes);
|
||||||
Assert.Equal("zima.inc", include.FilePath);
|
Assert.Equal("zima.inc", include.FilePath);
|
||||||
@@ -95,7 +92,7 @@ public class SceneryParserTests
|
|||||||
endoptional
|
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.Equal(2, include.Kind);
|
||||||
Assert.False(include.Selected);
|
Assert.False(include.Selected);
|
||||||
@@ -112,7 +109,7 @@ public class SceneryParserTests
|
|||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var trainset = Assert.Single(scenery.Trainsets);
|
var trainset = Assert.Single(scenery.Trainsets);
|
||||||
Assert.True(trainset.Parsed);
|
Assert.True(trainset.Parsed);
|
||||||
@@ -126,11 +123,11 @@ public class SceneryParserTests
|
|||||||
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
||||||
{
|
{
|
||||||
const string scn = """
|
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
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var vehicle = Assert.Single(scenery.LooseVehicles);
|
var vehicle = Assert.Single(scenery.LooseVehicles);
|
||||||
Assert.Equal("wagon_1", vehicle.Name);
|
Assert.Equal("wagon_1", vehicle.Name);
|
||||||
@@ -142,13 +139,43 @@ public class SceneryParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
|
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.Empty(scenery.LooseVehicles);
|
||||||
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
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);
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
578
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
578
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
@@ -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<Trainset>? 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<string> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A disk that gives out on whatever is being written; what it does to the file is <see cref="PhysicalFileSystemTests"/>' business.</summary>
|
||||||
|
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<IFileSystem, FailingDisk>();
|
||||||
|
((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<string>()),
|
||||||
|
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>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
using StarterNG.Infrastructure.Sceneries;
|
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryWeatherTests
|
public class SceneryWeatherTests
|
||||||
{
|
{
|
||||||
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Authored_weather_is_read_from_the_config_block()
|
public void Authored_weather_is_read_from_the_config_block()
|
||||||
{
|
{
|
||||||
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
|
|||||||
time 8:30 0 0 endtime
|
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.True(weather.IsAuthored);
|
||||||
Assert.Equal(172, weather.Day);
|
Assert.Equal(172, weather.Day);
|
||||||
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_scenery_without_a_time_starts_at_the_players_clock()
|
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.Equal("17:42", weather.ScenarioTimeOverride);
|
||||||
Assert.False(weather.IsAuthored);
|
Assert.False(weather.IsAuthored);
|
||||||
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Freshly_parsed_weather_counts_as_unchanged()
|
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);
|
Assert.False(weather.Changed);
|
||||||
}
|
}
|
||||||
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Editing_then_restoring_returns_to_the_authored_values()
|
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.Day = 5;
|
||||||
weather.Temperature = -12;
|
weather.Temperature = -12;
|
||||||
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
|
|||||||
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
||||||
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.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 _);
|
||||||
|
|
||||||
|
/// <summary>The block the writer puts at the top of the text, and the rest.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface ISceneryRepository
|
|||||||
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
||||||
|
|
||||||
Scenery? Load(string path);
|
Scenery? Load(string path);
|
||||||
|
|
||||||
|
/// <summary>The scenery files there are now; null while their folder is away.</summary>
|
||||||
|
IReadOnlyList<string>? ListFiles();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,19 @@ public sealed class GameLibrary
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
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
|
ReloadSceneries(progress is null
|
||||||
? null
|
? null
|
||||||
|
|||||||
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
@@ -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,
|
||||||
|
|
||||||
|
/// <summary>The file no longer holds the trainset as it was read - edited outside meanwhile.</summary>
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>.scn.bak</c> there and rewrites
|
||||||
|
/// it on every save.
|
||||||
|
/// </summary>
|
||||||
|
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<string, FileStamp?>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scenery file, or the included one the trainset came from - named, as
|
||||||
|
/// includes are, from the scenery folder the scenery itself is in.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>What became of a scenery's files since they were read.</summary>
|
||||||
|
/// <param name="Current">The scenery as it is held; null for a file new in the folder.</param>
|
||||||
|
/// <param name="Fresh">As its files have it now; null for one gone from the folder.</param>
|
||||||
|
public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
|
||||||
|
|
||||||
|
public enum SceneryVerdict
|
||||||
|
{
|
||||||
|
/// <summary>Nothing to do about it, for now or for good.</summary>
|
||||||
|
Leave,
|
||||||
|
|
||||||
|
/// <summary>The library can take it in.</summary>
|
||||||
|
Take,
|
||||||
|
|
||||||
|
/// <summary>Taking it in loses edits, so that is for someone to say.</summary>
|
||||||
|
AskFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What is to become of a change.</summary>
|
||||||
|
/// <param name="Lost">The consists with unsaved edits that <see cref="SceneryVerdict.AskFirst"/> is about.</param>
|
||||||
|
public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList<Trainset> Lost)
|
||||||
|
{
|
||||||
|
public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty<Trainset>());
|
||||||
|
public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty<Trainset>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string> _unreadable = new();
|
||||||
|
|
||||||
|
private HashSet<string> _away = new();
|
||||||
|
|
||||||
|
public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
|
||||||
|
{
|
||||||
|
_files = files;
|
||||||
|
_sceneries = sceneries;
|
||||||
|
_library = library;
|
||||||
|
_state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What has changed among the files of <paramref name="held"/>. Off the UI thread, one look at a time.</summary>
|
||||||
|
public IReadOnlyList<SceneryChange> Look(IReadOnlyList<Scenery> held)
|
||||||
|
{
|
||||||
|
var changes = new List<SceneryChange>();
|
||||||
|
if (_sceneries.ListFiles() is not { } files)
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
var there = files.ToHashSet();
|
||||||
|
var away = new HashSet<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether any of the files it was read from is no longer as it was.</summary>
|
||||||
|
public bool IsStale(Scenery scenery) =>
|
||||||
|
scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
|
||||||
|
|
||||||
|
private Scenery? LoadOnce(string path, IEnumerable<string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The consists with unsaved edits that have no place there: their text changed too.</returns>
|
||||||
|
public static IReadOnlyList<Trainset> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On the UI thread: the library, and what is picked in it, take the change in.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
@@ -58,6 +59,28 @@ public class Trainset
|
|||||||
|
|
||||||
public string? Mini;
|
public string? Mini;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "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.
|
||||||
|
/// </summary>
|
||||||
|
public bool Reversed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file the trainset is written in, as the scenery's include names it, for
|
||||||
|
/// one from a file put in place by <see cref="Domain.Sceneries.SceneryIncludes"/>;
|
||||||
|
/// null for the scenery's own.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceFile { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the speed, each vehicle and the <c>endtrainset</c> 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 <c>endtrainset</c> the end is the end of the text.
|
||||||
|
/// Known only of a trainset that was <see cref="Parsed"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; }
|
||||||
|
|
||||||
public Trainset(string trainsetEntry)
|
public Trainset(string trainsetEntry)
|
||||||
{
|
{
|
||||||
RawEntry = trainsetEntry;
|
RawEntry = trainsetEntry;
|
||||||
@@ -77,11 +100,7 @@ public class Trainset
|
|||||||
|
|
||||||
private void ParseEntry(string trainsetEntry)
|
private void ParseEntry(string trainsetEntry)
|
||||||
{
|
{
|
||||||
List<string> tokens = Regex
|
List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
|
||||||
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
|
|
||||||
.Select(m => m.Value)
|
|
||||||
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var match = Regex.Match(
|
var match = Regex.Match(
|
||||||
trainsetEntry,
|
trainsetEntry,
|
||||||
@@ -99,66 +118,92 @@ public class Trainset
|
|||||||
if (mini.Success)
|
if (mini.Success)
|
||||||
Mini = mini.Groups[1].Value.Trim();
|
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++)
|
for (int i = 0; i < tokens.Count; i++)
|
||||||
{
|
{
|
||||||
if (tokens[i] == "endtrainset")
|
if (Is(i, "endtrainset"))
|
||||||
break;
|
|
||||||
|
|
||||||
if (tokens[i] == "trainset")
|
|
||||||
{
|
{
|
||||||
this.Name = tokens[++i];
|
end = tokens[i].Index;
|
||||||
this.Track = tokens[++i];
|
break;
|
||||||
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
}
|
||||||
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
|
||||||
|
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;
|
this.OriginalVelocity = this.Velocity;
|
||||||
continue;
|
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")
|
Reversed |= Vehicles.Count == 0;
|
||||||
i++;
|
|
||||||
i++;
|
|
||||||
continue;
|
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();
|
Dynamic nodeDynamic = new Dynamic();
|
||||||
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.Name = tokens[++i];
|
nodeDynamic.Name = tokens[++i].Text;
|
||||||
i++;
|
i++;
|
||||||
nodeDynamic.DataFolder = tokens[++i];
|
nodeDynamic.DataFolder = tokens[++i].Text;
|
||||||
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
|
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
|
||||||
nodeDynamic.MmdFile = tokens[++i];
|
nodeDynamic.MmdFile = tokens[++i].Text;
|
||||||
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
nodeDynamic.DriverType = tokens[++i] switch
|
nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
|
||||||
{
|
|
||||||
"headdriver" => eDriverType.Headdriver,
|
|
||||||
"reardriver" => eDriverType.Reardriver,
|
|
||||||
"passenger" => eDriverType.Passenger,
|
|
||||||
_ => eDriverType.Nobody
|
|
||||||
};
|
|
||||||
|
|
||||||
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<string>();
|
var trailing = new List<string>();
|
||||||
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
|
while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
|
||||||
trailing.Add(tokens[++i]);
|
!Is(i + 1, "endtrainset"))
|
||||||
|
trailing.Add(tokens[++i].Text);
|
||||||
nodeDynamic.ReadTrailing(trailing);
|
nodeDynamic.ReadTrailing(trailing);
|
||||||
|
|
||||||
i++;
|
if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
|
||||||
|
i++;
|
||||||
|
|
||||||
Vehicles.Add(nodeDynamic);
|
Vehicles.Add(nodeDynamic);
|
||||||
|
spans.Add((start, tokens[i].End));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Layout = (speed, spans, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToSceneryEntry() =>
|
public string ToSceneryEntry() =>
|
||||||
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
||||||
|
|
||||||
|
/// <summary>No vehicles, in the export too: an entry that could not be read goes out as its raw text.</summary>
|
||||||
|
public void RemoveVehicles()
|
||||||
|
{
|
||||||
|
Vehicles = new List<Dynamic>();
|
||||||
|
if (!Parsed)
|
||||||
|
RawEntry = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public string GetTrainsetEntry()
|
public string GetTrainsetEntry()
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -177,13 +222,11 @@ public class Trainset
|
|||||||
sb.Append(Track).Append(' ');
|
sb.Append(Track).Append(' ');
|
||||||
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
|
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
|
||||||
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
|
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
|
||||||
|
if (Reversed)
|
||||||
|
sb.Append(" reversed");
|
||||||
sb.Append('\n');
|
sb.Append('\n');
|
||||||
|
|
||||||
string entry = sb.ToString();
|
return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
|
||||||
foreach (Dynamic vehicle in Vehicles)
|
|
||||||
entry += vehicle.ToTrainsetNode();
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +244,6 @@ public class Dynamic
|
|||||||
|
|
||||||
public Coupling Coupling = new();
|
public Coupling Coupling = new();
|
||||||
|
|
||||||
public byte couplingData
|
|
||||||
{
|
|
||||||
get => (byte)(Coupling.AbsFlags & 0xFF);
|
|
||||||
set => Coupling.Flags = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public const string PantState = "pantstate";
|
public const string PantState = "pantstate";
|
||||||
|
|
||||||
public const int PantStateMax = 3;
|
public const int PantStateMax = 3;
|
||||||
@@ -244,8 +281,6 @@ public class Dynamic
|
|||||||
public static bool IsPantStateType(string? type) =>
|
public static bool IsPantStateType(string? type) =>
|
||||||
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsPantState => IsPantStateType(LoadType);
|
|
||||||
|
|
||||||
internal void ReadTrailing(List<string> t)
|
internal void ReadTrailing(List<string> t)
|
||||||
{
|
{
|
||||||
int p = 0;
|
int p = 0;
|
||||||
@@ -278,14 +313,33 @@ public class Dynamic
|
|||||||
return dot > 0 ? token[..dot] : token;
|
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()
|
internal string WriteTrailing()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
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));
|
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);
|
sb.Append(' ').Append(LoadType);
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -314,37 +368,26 @@ public class Dynamic
|
|||||||
|
|
||||||
public string ToTrainsetNode()
|
public string ToTrainsetNode()
|
||||||
{
|
{
|
||||||
string driver = DriverType switch
|
|
||||||
{
|
|
||||||
eDriverType.Headdriver => "headdriver",
|
|
||||||
eDriverType.Reardriver => "reardriver",
|
|
||||||
eDriverType.Passenger => "passenger",
|
|
||||||
_ => "nobody"
|
|
||||||
};
|
|
||||||
|
|
||||||
return
|
return
|
||||||
$"node {RangeMax} {RangeMin} {Name} dynamic " +
|
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||||
$"{DataFolder} {SkinFile} {MmdFile} " +
|
$"{DataFolder} {SkinFile} {MmdFile} " +
|
||||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
|
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
|
$"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string ToTrainsetNodes(IEnumerable<Dynamic> vehicles) =>
|
||||||
|
string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
|
||||||
|
|
||||||
public string ToLooseNode()
|
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 path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
|
||||||
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
|
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
|
||||||
return
|
return
|
||||||
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||||
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
|
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
|
||||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
|
$"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
|
||||||
$"{WriteTrailing()} enddynamic\n";
|
$"{WriteTrailing()} enddynamic\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
StarterNG/Domain/Sceneries/FileStamp.cs
Normal file
23
StarterNG/Domain/Sceneries/FileStamp.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>A file as it stood, to tell whether it has been changed since.</summary>
|
||||||
|
public readonly record struct FileStamp(DateTime LastWriteUtc, long Size)
|
||||||
|
{
|
||||||
|
/// <summary>Null for one that is not there, or cannot be looked at just now.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
|
||||||
|
|
||||||
namespace StarterNG.Domain.Sceneries;
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
@@ -59,10 +57,16 @@ public sealed class Scenery
|
|||||||
|
|
||||||
public List<string> LooseVehicleNames { get; } = new();
|
public List<string> LooseVehicleNames { get; } = new();
|
||||||
|
|
||||||
public List<string> Faults { get; } = new();
|
public List<SceneryFault> Faults { get; } = new();
|
||||||
|
|
||||||
public SceneryWeather Weather { get; } = new();
|
public SceneryWeather Weather { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, FileStamp?> Sources { get; set; } = new Dictionary<string, FileStamp?>();
|
||||||
|
|
||||||
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||||
|
|
||||||
public string LocalizedDescription(ISceneryTranslations translations) =>
|
public string LocalizedDescription(ISceneryTranslations translations) =>
|
||||||
@@ -81,7 +85,9 @@ public sealed class Scenery
|
|||||||
|
|
||||||
for (int i = 0; i < Trainsets.Count; i++)
|
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
|
? string.Empty
|
||||||
: Trainsets[i].ToSceneryEntry();
|
: Trainsets[i].ToSceneryEntry();
|
||||||
result = result.Replace("{{" + i + "}}", entry);
|
result = result.Replace("{{" + i + "}}", entry);
|
||||||
|
|||||||
85
StarterNG/Domain/Sceneries/SceneryCode.cs
Normal file
85
StarterNG/Domain/Sceneries/SceneryCode.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scenery's text read the way the simulator's parser reads it: comments -
|
||||||
|
/// <c>//</c> to the end of the line, <c>/* */</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;]+");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text with every comment blanked out, line ends kept, so a place in one
|
||||||
|
/// is the same place in the other.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="unclosed">Where a <c>/*</c> that never ends starts, or -1.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="code">Text with its comments blanked out by <see cref="Mask"/>.</param>
|
||||||
|
public static List<Token> Tokens(string code)
|
||||||
|
{
|
||||||
|
var tokens = new List<Token>();
|
||||||
|
for (var m = Word.Match(code); m.Success; m = m.NextMatch())
|
||||||
|
tokens.Add(new Token(m.Value, m.Index));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
StarterNG/Domain/Sceneries/SceneryFault.cs
Normal file
104
StarterNG/Domain/Sceneries/SceneryFault.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
public enum SceneryFaultKind
|
||||||
|
{
|
||||||
|
/// <summary><c>/*</c> never closed: the simulator skips the rest of the file.</summary>
|
||||||
|
UnclosedComment,
|
||||||
|
|
||||||
|
/// <summary>A trainset with no <c>endtrainset</c>; the simulator ends it at the next one.</summary>
|
||||||
|
TrainsetNotClosed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
TrainsetHeaderRepeated,
|
||||||
|
|
||||||
|
/// <summary>The header lacks its name, track, offset or speed.</summary>
|
||||||
|
TrainsetIncomplete,
|
||||||
|
|
||||||
|
StrayEndTrainset,
|
||||||
|
|
||||||
|
/// <summary><c>reversed</c> outside a trainset, or after its first vehicle.</summary>
|
||||||
|
ReversedMisplaced,
|
||||||
|
|
||||||
|
/// <summary>A vehicle with no <c>enddynamic</c>, which swallows what follows.</summary>
|
||||||
|
VehicleNotClosed,
|
||||||
|
|
||||||
|
VehicleIncomplete,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A vehicle with no load count: the simulator takes its <c>enddynamic</c> for
|
||||||
|
/// one and goes on to the next.
|
||||||
|
/// </summary>
|
||||||
|
LoadCountMissing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A load count other than 0 with no type after it - the simulator takes the
|
||||||
|
/// <c>enddynamic</c> for the type; <see cref="SceneryFault.Token"/> holds the count.
|
||||||
|
/// </summary>
|
||||||
|
LoadTypeMissing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One word between the load and <c>enddynamic</c>: the simulator takes it for
|
||||||
|
/// a destination, the <c>enddynamic</c> for its name, and goes on to the next.
|
||||||
|
/// <see cref="SceneryFault.Token"/> holds the word.
|
||||||
|
/// </summary>
|
||||||
|
WordBeforeEnd,
|
||||||
|
|
||||||
|
/// <summary>A vehicle written as a trainset's is, with no trainset around it.</summary>
|
||||||
|
VehicleOutsideTrainset,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trainset's track named with a space, which splits it into words; the
|
||||||
|
/// <see cref="SceneryFault.Token"/> holds the name as written.
|
||||||
|
/// </summary>
|
||||||
|
TrackNameSplit,
|
||||||
|
|
||||||
|
/// <summary>Text where a number belongs; <see cref="SceneryFault.Token"/> holds it.</summary>
|
||||||
|
NotANumber,
|
||||||
|
|
||||||
|
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
|
||||||
|
MissingInclude,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
|
||||||
|
/// </summary>
|
||||||
|
IncludeLetterCase,
|
||||||
|
|
||||||
|
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
|
||||||
|
IncludeNotClosed,
|
||||||
|
|
||||||
|
/// <summary>A trainset the starter could not read, for a reason none of the above names.</summary>
|
||||||
|
TrainsetUnreadable,
|
||||||
|
|
||||||
|
/// <summary>A vehicle outside a trainset the starter could not read.</summary>
|
||||||
|
VehicleUnreadable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Subject">The trainset or vehicle it concerns, when it has a name.</param>
|
||||||
|
public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null,
|
||||||
|
string? Token = null)
|
||||||
|
{
|
||||||
|
/// <summary>Where in the scenery's text it was found, to tell which trainset it falls in.</summary>
|
||||||
|
public int Offset { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? Lost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? Unmade { get; init; }
|
||||||
|
}
|
||||||
37
StarterNG/Domain/Sceneries/SceneryFaultText.cs
Normal file
37
StarterNG/Domain/Sceneries/SceneryFaultText.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>A scenery's faults as they are read: where each one is, in a column, and what it is.</summary>
|
||||||
|
public static class SceneryFaultText
|
||||||
|
{
|
||||||
|
/// <param name="limit">Most it lists; the rest are only counted.</param>
|
||||||
|
/// <param name="text">The wording for a key, in the language shown.</param>
|
||||||
|
public static string Format(IReadOnlyList<SceneryFault> faults, int limit, Func<string, string> 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<string, string> text) =>
|
||||||
|
string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? "");
|
||||||
|
}
|
||||||
78
StarterNG/Domain/Sceneries/SceneryIncludes.cs
Normal file
78
StarterNG/Domain/Sceneries/SceneryIncludes.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the plain <c>include path end</c> form: an include with parameters needs
|
||||||
|
/// them put in, and one whose <c>end</c> is not on its line is read by the
|
||||||
|
/// simulator in ways not worth guessing. One marked <c>$optional</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
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));
|
||||||
|
|
||||||
|
/// <param name="read">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
public static string Inline(string text, Func<string, (string File, string Text)?> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The text without the inlined files: what the scenery itself says.</summary>
|
||||||
|
public static string WithoutInlined(string text) => Marked.Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>Where each inlined file sits in the text, by the name its include gave.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
66
StarterNG/Domain/Sceneries/SceneryLines.cs
Normal file
66
StarterNG/Domain/Sceneries/SceneryLines.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which file and line a place in a scenery's text is - its own file, or one put
|
||||||
|
/// in place of its include by <see cref="SceneryIncludes"/>, counted from that
|
||||||
|
/// file's own first line.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneryLines
|
||||||
|
{
|
||||||
|
private readonly List<int> _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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A mistake at a place in the text, told by its file and line.</summary>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
528
StarterNG/Domain/Sceneries/ScenerySyntax.cs
Normal file
528
StarterNG/Domain/Sceneries/ScenerySyntax.cs
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>enddynamic</c> - 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ScenerySyntax
|
||||||
|
{
|
||||||
|
public sealed record Result(
|
||||||
|
IReadOnlyList<(int Start, int End)> Trainsets,
|
||||||
|
IReadOnlyList<(int Start, int End)> LooseVehicles,
|
||||||
|
IReadOnlyList<SceneryFault> Faults,
|
||||||
|
SceneryLines Lines);
|
||||||
|
|
||||||
|
/// <param name="text">The scenery's text, its includes put in place.</param>
|
||||||
|
/// <param name="file">The scenery's file name, for faults in its own text.</param>
|
||||||
|
/// <param name="findInclude">
|
||||||
|
/// What the simulator makes of an include, named as the include names it; null
|
||||||
|
/// leaves includes unchecked.
|
||||||
|
/// </param>
|
||||||
|
public static Result Scan(string text, string file, Func<string, IncludeLookup>? 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static List<SceneryFault> Unmade(Reading simulator, Reading author, SceneryLines lines)
|
||||||
|
{
|
||||||
|
var unmade = new List<(int Start, int End, int Cause)>();
|
||||||
|
var lost = new Dictionary<int, List<string>>();
|
||||||
|
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<string>();
|
||||||
|
names.Add(named ? name : $"{file}:{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmade.Count == 0)
|
||||||
|
return simulator.Faults;
|
||||||
|
|
||||||
|
var faults = new List<SceneryFault>();
|
||||||
|
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<string> 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<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
|
||||||
|
{
|
||||||
|
public readonly List<(int Start, int End, string Name)> Trainsets = new();
|
||||||
|
public readonly List<(int, int)> Loose = new();
|
||||||
|
public readonly List<SceneryFault> Faults = new();
|
||||||
|
|
||||||
|
/// <summary>The text each fault makes the simulator pass over - from, to - and where the fault is.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The entries starting past <paramref name="after"/> up to <paramref name="last"/>,
|
||||||
|
/// as they would be named in the text - vehicles by their names - or null
|
||||||
|
/// when none start there.
|
||||||
|
/// </summary>
|
||||||
|
private string? Lost(int after, int last)
|
||||||
|
{
|
||||||
|
var entries = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the <paramref name="count"/> words after the one at <paramref name="at"/> are all there and none starts something else.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="swallowed">The words the simulator takes along with it, past the one given up to the last.</param>
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static int? SplitTrack(List<SceneryCode.Token> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An include named outright - not one built from a template's parameters or
|
||||||
|
/// picked at random, which only the simulator can tell.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Plain(string name) =>
|
||||||
|
name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
name.IndexOfAny(new[] { '*', '?' }) < 0;
|
||||||
|
}
|
||||||
183
StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
Normal file
183
StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneryTrainsetText
|
||||||
|
{
|
||||||
|
private static readonly Regex LoneLineFeed = new(@"(?<!\r)\n");
|
||||||
|
|
||||||
|
private static readonly Regex RestOfLine = new(@"^[ \t]*\r?\n?");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trainset as the file should now hold it, in the file's own line ends.
|
||||||
|
/// Ends at <c>endtrainset</c>, as the parser's match of the old one did.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file with the trainset's old text swapped for <paramref name="entry"/>,
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static string? Replace(string fileText, Trainset trainset, IReadOnlyList<Trainset> 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<int>();
|
||||||
|
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)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsModified(Trainset trainset) =>
|
||||||
|
trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry);
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> 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");
|
||||||
|
}
|
||||||
@@ -69,6 +69,23 @@ public sealed class SceneryWeather
|
|||||||
Dirty = true;
|
Dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What was set by hand stays over a file read anew; untouched, the file's own stands.</summary>
|
||||||
|
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)
|
public void SetFogRange(int start, int end, IRandomSource random)
|
||||||
{
|
{
|
||||||
FogStart = Math.Clamp(start, FogMin, FogMax);
|
FogStart = Math.Clamp(start, FogMin, FogMax);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
|
|||||||
|
|
||||||
public static class SceneryWeatherWriter
|
public static class SceneryWeatherWriter
|
||||||
{
|
{
|
||||||
private static readonly Regex ManagedConfigLine = new(
|
// Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
|
||||||
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
|
private const string WordStart = @"(?<![^\s;])";
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private const string WordEnd = @"(?![^\s;])";
|
||||||
|
|
||||||
|
private static readonly Regex ConfigBlock = new(
|
||||||
|
$@"(?is){WordStart}config{WordEnd}(.*?){WordStart}endconfig{WordEnd}");
|
||||||
|
|
||||||
|
private static readonly Regex ManagedSetting = new(
|
||||||
|
$@"(?i){WordStart}(movelight|scenario\.weather\.temperature|scenario\.time\.override)\s+\S+");
|
||||||
|
|
||||||
|
/// <summary>What the written block takes the place of - looked for in the code alone, never in a comment.</summary>
|
||||||
|
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)
|
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
|
||||||
{
|
{
|
||||||
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
|
|||||||
string carried = CarriedConfigLines(text);
|
string carried = CarriedConfigLines(text);
|
||||||
|
|
||||||
string stripped = text;
|
string stripped = text;
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
foreach (var pattern in Replaced)
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
stripped = Cut(stripped, pattern);
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
|
|
||||||
|
|
||||||
var inv = CultureInfo.InvariantCulture;
|
var inv = CultureInfo.InvariantCulture;
|
||||||
string config =
|
string config =
|
||||||
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>//</c> to end
|
||||||
|
/// where it did.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static string DelphiHourMinute(string hhmm)
|
||||||
{
|
{
|
||||||
int colon = hhmm.IndexOf(':');
|
int colon = hhmm.IndexOf(':');
|
||||||
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
|
|||||||
: "0" + hhmm[colon..];
|
: "0" + hhmm[colon..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The settings of every config block but those the starter writes itself.</summary>
|
||||||
private static string CarriedConfigLines(string text)
|
private static string CarriedConfigLines(string text)
|
||||||
{
|
{
|
||||||
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
|
|
||||||
if (!block.Success)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (string raw in block.Groups[1].Value.Split('\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.TrimEnd('\r').Trim();
|
{
|
||||||
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
|
string line = raw.Trim();
|
||||||
continue;
|
if (line.Length > 0)
|
||||||
sb.Append(line).Append("\r\n");
|
sb.Append(line).Append("\r\n");
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
StarterNG/Domain/SimulatorNames.cs
Normal file
18
StarterNG/Domain/SimulatorNames.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>-s</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
|
||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
namespace StarterNG.Infrastructure.Sceneries;
|
namespace StarterNG.Infrastructure.Sceneries;
|
||||||
@@ -21,58 +20,97 @@ public sealed class SceneryParser
|
|||||||
_random = random;
|
_random = random;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Scenery Parse(string path, string content)
|
/// <param name="findInclude">
|
||||||
|
/// What the simulator makes of an include, as the include names it; null leaves
|
||||||
|
/// includes unchecked.
|
||||||
|
/// </param>
|
||||||
|
public Scenery Parse(string path, string content, Func<string, IncludeLookup>? findInclude = null)
|
||||||
{
|
{
|
||||||
string original = content;
|
string original = content;
|
||||||
|
|
||||||
var faults = new List<string>();
|
// A file put in place of its include has directives of its own, for itself.
|
||||||
var attachments = ParseAttachments(original);
|
string own = SceneryIncludes.WithoutInlined(original);
|
||||||
|
|
||||||
|
var attachments = ParseAttachments(own);
|
||||||
|
|
||||||
var includes = new List<SceneryInclude>();
|
var includes = new List<SceneryInclude>();
|
||||||
content = StripOptionalIncludes(content, includes);
|
content = StripOptionalIncludes(content, includes);
|
||||||
|
|
||||||
var trainsetEntries = new List<string>();
|
// Read from the text with its optional includes blanked, which keeps every
|
||||||
content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b",
|
// place where it was, so a fault's line is the line in the file.
|
||||||
match =>
|
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude);
|
||||||
{
|
var faults = syntax.Faults.ToList();
|
||||||
trainsetEntries.Add(match.Value);
|
var inlined = SceneryIncludes.Ranges(content);
|
||||||
return $"{{{{{trainsetEntries.Count - 1}}}}}";
|
|
||||||
},
|
|
||||||
RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
|
var trainsets = new List<Trainset>();
|
||||||
var looseVehicles = new List<Dynamic>();
|
var looseVehicles = new List<Dynamic>();
|
||||||
var looseNames = new List<string>();
|
var looseNames = new List<string>();
|
||||||
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"),
|
var trainset = new Trainset(content[start..end]) { SourceFile = SceneryIncludes.FileAt(inlined, start) };
|
||||||
Name = MatchDirective(original, "n"),
|
if (!trainset.Parsed && !faults.Any(f => f.Offset >= start && f.Offset < end))
|
||||||
Description = MatchAllDirectives(original, "d"),
|
faults.Add(syntax.Lines.Fault(SceneryFaultKind.TrainsetUnreadable, start, trainset.Name));
|
||||||
ImageName = MatchDirective(original, "i"),
|
cuts.Add((start, end, $"{{{{{trainsets.Count}}}}}"));
|
||||||
Archival = MatchDirective(original, "a") != null
|
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.Attachments.AddRange(attachments);
|
||||||
scenery.Includes.AddRange(includes);
|
scenery.Includes.AddRange(includes);
|
||||||
|
scenery.Trainsets.AddRange(trainsets);
|
||||||
scenery.LooseVehicles.AddRange(looseVehicles);
|
scenery.LooseVehicles.AddRange(looseVehicles);
|
||||||
scenery.LooseVehicleNames.AddRange(looseNames);
|
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();
|
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;
|
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<SceneryAttachment> ParseAttachments(string content)
|
private static List<SceneryAttachment> ParseAttachments(string content)
|
||||||
{
|
{
|
||||||
var attachments = new List<SceneryAttachment>();
|
var attachments = new List<SceneryAttachment>();
|
||||||
@@ -115,7 +153,7 @@ public sealed class SceneryParser
|
|||||||
|
|
||||||
return Regex.Replace(
|
return Regex.Replace(
|
||||||
content,
|
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 =>
|
m =>
|
||||||
{
|
{
|
||||||
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
|
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
|
||||||
@@ -137,65 +175,55 @@ public sealed class SceneryParser
|
|||||||
Kind = kind,
|
Kind = kind,
|
||||||
Selected = kind == 1
|
Selected = kind == 1
|
||||||
});
|
});
|
||||||
return " ";
|
return Regex.Replace(m.Value, @"[^\r\n]", " ");
|
||||||
},
|
},
|
||||||
RegexOptions.IgnoreCase);
|
RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractLooseVehicles(string content, List<Dynamic> vehicles, List<string> names,
|
private static readonly Regex LooseNode = new(
|
||||||
List<string> faults)
|
@"(?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 ?? "";
|
||||||
|
|
||||||
|
/// <summary>A vehicle standing on its own, or null when its node cannot be read.</summary>
|
||||||
|
private static Dynamic? ReadLooseVehicle(string node)
|
||||||
{
|
{
|
||||||
var inv = CultureInfo.InvariantCulture;
|
var inv = CultureInfo.InvariantCulture;
|
||||||
|
var m = LooseNode.Match(SceneryCode.Mask(node, out _));
|
||||||
|
if (!m.Success)
|
||||||
|
return null;
|
||||||
|
|
||||||
return Regex.Replace(
|
try
|
||||||
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",
|
var vehicle = new Dynamic
|
||||||
m =>
|
|
||||||
{
|
{
|
||||||
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))
|
vehicle.ReadTrailing(m.Groups[11].Value
|
||||||
return m.Value;
|
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.ToList());
|
||||||
try
|
return vehicle;
|
||||||
{
|
}
|
||||||
var vehicle = new Dynamic
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
RangeMax = float.Parse(m.Groups[1].Value, inv),
|
return null;
|
||||||
RangeMin = float.Parse(m.Groups[2].Value, inv),
|
}
|
||||||
Name = name,
|
catch (OverflowException)
|
||||||
DataFolder = m.Groups[4].Value,
|
{
|
||||||
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
|
return null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ParseWeather(string content, SceneryWeather weather)
|
private void ParseWeather(string content, SceneryWeather weather)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ using System.Collections.Concurrent;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Domain;
|
||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
@@ -21,6 +21,9 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
private readonly SceneryParser _parser;
|
private readonly SceneryParser _parser;
|
||||||
|
|
||||||
|
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
|
||||||
|
private readonly ConcurrentDictionary<(string, bool), IReadOnlyList<string>> _listings = new();
|
||||||
|
|
||||||
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
@@ -31,6 +34,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
|
|
||||||
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
||||||
{
|
{
|
||||||
|
_listings.Clear();
|
||||||
var files = SceneryFiles().ToList();
|
var files = SceneryFiles().ToList();
|
||||||
var loaded = new ConcurrentBag<Scenery>();
|
var loaded = new ConcurrentBag<Scenery>();
|
||||||
int total = Math.Max(1, files.Count);
|
int total = Math.Max(1, files.Count);
|
||||||
@@ -40,7 +44,7 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
|
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
|
||||||
file =>
|
file =>
|
||||||
{
|
{
|
||||||
var scenery = Load(file);
|
var scenery = LoadOne(file);
|
||||||
if (scenery is not null)
|
if (scenery is not null)
|
||||||
loaded.Add(scenery);
|
loaded.Add(scenery);
|
||||||
|
|
||||||
@@ -52,14 +56,44 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Scenery? Load(string path)
|
public Scenery? Load(string path)
|
||||||
|
{
|
||||||
|
_listings.Clear();
|
||||||
|
return LoadOne(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string>? ListFiles() =>
|
||||||
|
_files.DirectoryExists(_paths.Scenery) ? SceneryFiles().ToList() : null;
|
||||||
|
|
||||||
|
private Scenery? LoadOne(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!_files.FileExists(path))
|
if (!_files.FileExists(path))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path)));
|
// Looked at ahead of the reading: changed meanwhile, it counts as changed.
|
||||||
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
|
var sources = new Dictionary<string, FileStamp?> { [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;
|
return scenery;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -69,6 +103,58 @@ public sealed class SceneryRepository : ISceneryRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool Opens(string name) =>
|
||||||
|
_files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/'))));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
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<string> Listing(string directory, bool files) =>
|
||||||
|
_listings.GetOrAdd((directory, files), key =>
|
||||||
|
{
|
||||||
|
if (!_files.DirectoryExists(key.Item1))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
|
||||||
|
});
|
||||||
|
|
||||||
private IEnumerable<string> SceneryFiles() =>
|
private IEnumerable<string> SceneryFiles() =>
|
||||||
_files.GetFiles(_paths.Scenery, "*.scn")
|
_files.GetFiles(_paths.Scenery, "*.scn")
|
||||||
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
|
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
namespace StarterNG.Infrastructure.Sceneries;
|
namespace StarterNG.Infrastructure.Sceneries;
|
||||||
|
|
||||||
@@ -10,6 +12,10 @@ public sealed class TimetableLocator
|
|||||||
{
|
{
|
||||||
private const string NoTimetable = "none";
|
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;
|
private readonly IFileSystem _files;
|
||||||
|
|
||||||
public TimetableLocator(IFileSystem files)
|
public TimetableLocator(IFileSystem files)
|
||||||
@@ -36,4 +42,22 @@ public sealed class TimetableLocator
|
|||||||
|
|
||||||
return candidates.FirstOrDefault(_files.FileExists);
|
return candidates.FirstOrDefault(_files.FileExists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The number the timetable gives its train; null with no timetable or no such heading in it.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ public partial class Scenarios : UserControl
|
|||||||
private void PopulateFaults(Scenery scenery)
|
private void PopulateFaults(Scenery scenery)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
lines.AddRange(scenery.Faults);
|
lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key]));
|
||||||
|
|
||||||
var db = AppServices.Current.Library.Vehicles;
|
var db = AppServices.Current.Library.Vehicles;
|
||||||
foreach (var ts in scenery.Trainsets)
|
foreach (var ts in scenery.Trainsets)
|
||||||
|
|||||||
Reference in New Issue
Block a user