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:
maj00r
2026-11-01 16:06:15 +01:00
parent b6735fe345
commit 69aa075bfa
30 changed files with 3596 additions and 215 deletions

View 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());
}

View File

@@ -72,4 +72,45 @@ public class GameLibraryTests
Assert.Contains("textures.txt", installation.Log.Text);
}
[Fact]
public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself()
{
var files = new InMemoryFileSystem()
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09")
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2")
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1")
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
.DenyListing(TestInstallation.At("dynamic", "aaa"));
var installation = new TestInstallation(files);
installation.Library.Load();
Assert.True(installation.Library.Loaded);
Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile);
Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name);
Assert.Contains("aaa", installation.Log.Text);
Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
}
[Fact]
public void The_whole_rolling_stock_failing_still_leaves_the_sceneries()
{
var installation = Complete();
var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures,
installation.Physics, installation.Log);
library.Load();
Assert.True(library.Loaded);
Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
Assert.Contains("no disk today", installation.Log.Text);
}
private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository
{
public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) =>
throw new IOException("no disk today");
}
}

View File

@@ -1,13 +1,9 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryExportTests
{
private static SceneryParser Parser() =>
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
[Fact]
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
{
@@ -18,7 +14,7 @@ public class SceneryExportTests
endtrainset
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -27,6 +23,45 @@ public class SceneryExportTests
Assert.Contains("endtrainset", exported);
}
[Fact]
public void A_consist_emptied_stays_out_of_the_export()
{
const string scn = """
//$n Test
trainset pociag tor_1 10 0
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
endtrainset
FirstInit
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Trainsets[0].RemoveVehicles();
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("trainset pociag", exported);
Assert.DoesNotContain("ep07-001", exported);
Assert.Contains("FirstInit", exported);
}
[Fact]
public void An_entry_that_could_not_be_read_goes_when_emptied_too()
{
const string scn = """
//$n Test
trainset pociag tor_1 10 0
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
endtrainset
FirstInit
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Trainsets[0].Parsed = false;
scenery.Trainsets[0].RemoveVehicles();
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("ep07-001", exported);
}
[Fact]
public void Selected_optional_includes_are_written_back_before_FirstInit()
{
@@ -36,7 +71,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -54,7 +89,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Includes[0].Selected = false;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -71,7 +106,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.HasCompanionTimetable = false;
Assert.Contains("include rozklad.inc",
@@ -95,7 +130,7 @@ public class SceneryExportTests
time 8:30 0 0 endtime
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Weather.Day = 200;
scenery.Weather.Temperature = 25;
@@ -120,7 +155,7 @@ public class SceneryExportTests
endtrainset
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.True(scenery.Trainsets[0].Decor);
Assert.DoesNotContain("trainset ai_1",
@@ -136,11 +171,28 @@ public class SceneryExportTests
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("wagon_1", exported);
Assert.Contains("enddynamic", exported);
}
[Theory]
[InlineData(0, "3 0 enddynamic")]
[InlineData(3, "3 3 pantstate enddynamic")]
public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail)
{
// After a 0 the simulator would read the type as a destination and take
// the next vehicle with it.
var car = new StarterNG.Classes.Dynamic
{
Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m",
LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count
};
car.Coupling.Flags = 3;
Assert.EndsWith(tail + "\n", car.ToTrainsetNode());
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -1,13 +1,10 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Domain.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryParserTests
{
private static SceneryParser Parser(DateTime? now = null) =>
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
[Fact]
public void Directives_become_the_scenery_headline()
{
@@ -19,7 +16,7 @@ public class SceneryParserTests
//$i images/kato.jpg
""";
var scenery = Parser().Parse("scenery/kato.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn);
Assert.Equal("Śląsk", scenery.Group);
Assert.Equal("Osobowy do Katowic", scenery.Name);
@@ -32,7 +29,7 @@ public class SceneryParserTests
[Fact]
public void The_a_directive_marks_a_scenery_archival()
{
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
Assert.True(scenery.Archival);
}
@@ -46,7 +43,7 @@ public class SceneryParserTests
//$f doc/notes.txt
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Collection(scenery.Attachments,
first =>
@@ -76,7 +73,7 @@ public class SceneryParserTests
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var include = Assert.Single(scenery.Includes);
Assert.Equal("zima.inc", include.FilePath);
@@ -95,7 +92,7 @@ public class SceneryParserTests
endoptional
""";
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes);
Assert.Equal(2, include.Kind);
Assert.False(include.Selected);
@@ -112,7 +109,7 @@ public class SceneryParserTests
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Parsed);
@@ -126,11 +123,11 @@ public class SceneryParserTests
public void Vehicles_outside_a_trainset_are_collected_as_loose()
{
const string scn = """
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var vehicle = Assert.Single(scenery.LooseVehicles);
Assert.Equal("wagon_1", vehicle.Name);
@@ -142,13 +139,43 @@ public class SceneryParserTests
[Fact]
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
{
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n";
var scenery = Parser().Parse("scenery/x.scn", scn);
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Empty(scenery.LooseVehicles);
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
var fault = Assert.Single(scenery.Faults);
Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line));
Assert.Contains("enddynamic", scenery.Template);
}
[Fact]
public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own()
{
const string included = """
//$n Auta
//$l Inne
//$d Opis aut
//$f doc/auta.pdf Auta
//$i auta.jpg
//$a
trainset auto road1 0 50
endtrainset
""";
string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n",
name => (name, included));
var scenery = TestScenery.Parser().Parse("scenery/x.scn", text);
Assert.Equal("Opis scenerii", scenery.Description);
Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath);
Assert.Null(scenery.Name);
Assert.Null(scenery.Group);
Assert.Null(scenery.ImageName);
Assert.False(scenery.Archival);
// The file's own trainset is still listed, and its text still written for a start.
Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
}
}

View 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);
}
}

View 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"));
}
}

View File

@@ -1,13 +1,10 @@
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryWeatherTests
{
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
[Fact]
public void Authored_weather_is_read_from_the_config_block()
{
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
time 8:30 0 0 endtime
""";
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
Assert.True(weather.IsAuthored);
Assert.Equal(172, weather.Day);
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
[Fact]
public void A_scenery_without_a_time_starts_at_the_players_clock()
{
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
Assert.Equal("17:42", weather.ScenarioTimeOverride);
Assert.False(weather.IsAuthored);
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
[Fact]
public void Freshly_parsed_weather_counts_as_unchanged()
{
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
Assert.False(weather.Changed);
}
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
[Fact]
public void Editing_then_restoring_returns_to_the_authored_values()
{
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
weather.Day = 5;
weather.Temperature = -12;
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
}
private static string Rewritten(string scn) =>
SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom());
private static string Code(string text) => SceneryCode.Mask(text, out _);
/// <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);
}
}

View File

@@ -11,4 +11,7 @@ public interface ISceneryRepository
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
Scenery? Load(string path);
/// <summary>The scenery files there are now; null while their folder is away.</summary>
IReadOnlyList<string>? ListFiles();
}

View File

@@ -47,10 +47,19 @@ public sealed class GameLibrary
return;
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload);
// Whatever goes wrong with the rolling stock, the sceneries still get their turn.
try
{
if (_vehicles.Load(Vehicles) == 0)
_log.Log("dynamic: brak plikow textures.txt");
Parallel.Invoke(_minis.Preload, _physics.Preload);
}
catch (Exception ex)
{
_log.Log("dynamic: loading the rolling stock", ex);
}
ReloadSceneries(progress is null
? null

View 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;
}

View 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;
}
}

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes;
@@ -58,6 +59,28 @@ public class Trainset
public string? Mini;
/// <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)
{
RawEntry = trainsetEntry;
@@ -77,11 +100,7 @@ public class Trainset
private void ParseEntry(string trainsetEntry)
{
List<string> tokens = Regex
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
.Select(m => m.Value)
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList();
List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
var match = Regex.Match(
trainsetEntry,
@@ -99,66 +118,92 @@ public class Trainset
if (mini.Success)
Mini = mini.Groups[1].Value.Trim();
// The simulator reads its words in lower case: "Trainset" is a trainset too.
bool Is(int at, string word) => tokens[at].Is(word);
SceneryCode.Token? speed = null;
var spans = new List<(int Start, int End)>();
int end = trainsetEntry.Length;
for (int i = 0; i < tokens.Count; i++)
{
if (tokens[i] == "endtrainset")
break;
if (tokens[i] == "trainset")
if (Is(i, "endtrainset"))
{
this.Name = tokens[++i];
this.Track = tokens[++i];
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
end = tokens[i].Index;
break;
}
if (Is(i, "trainset"))
{
this.Name = tokens[++i].Text;
this.Track = tokens[++i].Text;
this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
speed = tokens[++i];
this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture);
this.OriginalVelocity = this.Velocity;
continue;
}
if (tokens[i] == "assignments")
// Ahead of the vehicles; the simulator refuses it after the first one.
if (Is(i, "reversed"))
{
while (i < tokens.Count && tokens[i] != "endassignment")
i++;
i++;
Reversed |= Vehicles.Count == 0;
continue;
}
if (tokens[i] == "node")
if (Is(i, "assignment") || Is(i, "assignments"))
{
while (i < tokens.Count && !Is(i, "endassignment"))
i++;
continue;
}
if (Is(i, "node"))
{
int start = tokens[i].Index;
Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i];
nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i].Text;
i++;
nodeDynamic.DataFolder = tokens[++i];
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
nodeDynamic.MmdFile = tokens[++i];
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.DataFolder = tokens[++i].Text;
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
nodeDynamic.MmdFile = tokens[++i].Text;
nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
nodeDynamic.DriverType = tokens[++i] switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text);
// A missing enddynamic does not take the next vehicle with it.
var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]);
while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
!Is(i + 1, "endtrainset"))
trailing.Add(tokens[++i].Text);
nodeDynamic.ReadTrailing(trailing);
i++;
if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
i++;
Vehicles.Add(nodeDynamic);
spans.Add((start, tokens[i].End));
}
}
Layout = (speed, spans, end);
}
public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
/// <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()
{
@@ -177,13 +222,11 @@ public class Trainset
sb.Append(Track).Append(' ');
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
if (Reversed)
sb.Append(" reversed");
sb.Append('\n');
string entry = sb.ToString();
foreach (Dynamic vehicle in Vehicles)
entry += vehicle.ToTrainsetNode();
return entry;
return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
}
}
@@ -201,12 +244,6 @@ public class Dynamic
public Coupling Coupling = new();
public byte couplingData
{
get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value;
}
public const string PantState = "pantstate";
public const int PantStateMax = 3;
@@ -244,8 +281,6 @@ public class Dynamic
public static bool IsPantStateType(string? type) =>
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
public bool IsPantState => IsPantStateType(LoadType);
internal void ReadTrailing(List<string> t)
{
int p = 0;
@@ -278,14 +313,33 @@ public class Dynamic
return dot > 0 ? token[..dot] : token;
}
internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
private string DriverWord => DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
internal string WriteTrailing()
{
var sb = new StringBuilder();
bool loaded = !string.IsNullOrEmpty(LoadType);
bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0;
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
if (loaded && (LoadCount > 0 || IsPantState))
// The simulator reads a type only after a count above 0. One after a 0 it
// takes for a destination, and with it the next vehicle, up to its enddynamic.
// Pantographs set to none are the simulator's default anyway.
if (loaded)
sb.Append(' ').Append(LoadType);
return sb.ToString();
@@ -314,37 +368,26 @@ public class Dynamic
public string ToTrainsetNode()
{
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
return
$"node {RangeMax} {RangeMin} {Name} dynamic " +
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
$"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
}
public static string ToTrainsetNodes(IEnumerable<Dynamic> vehicles) =>
string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
public string ToLooseNode()
{
string driver = DriverType switch
{
eDriverType.Headdriver => "headdriver",
eDriverType.Reardriver => "reardriver",
eDriverType.Passenger => "passenger",
_ => "nobody"
};
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
return
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
$"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
$"{WriteTrailing()} enddynamic\n";
}
}

View 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;
}
}
}

View File

@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Domain.Sceneries;
@@ -59,10 +57,16 @@ public sealed class Scenery
public List<string> LooseVehicleNames { get; } = new();
public List<string> Faults { get; } = new();
public List<SceneryFault> Faults { 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 LocalizedDescription(ISceneryTranslations translations) =>
@@ -81,7 +85,9 @@ public sealed class Scenery
for (int i = 0; i < Trainsets.Count; i++)
{
string entry = skipDecorTrainsets && Trainsets[i].Decor
// The simulator logs an empty trainset as a bad one and does nothing else with it.
bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0;
string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor)
? string.Empty
: Trainsets[i].ToSceneryEntry();
result = result.Replace("{{" + i + "}}", entry);

View 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;
}
}

View 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; }
}

View 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 ?? "");
}

View 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;
}

View 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 };
}
}

View 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;
}

View 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");
}

View File

@@ -69,6 +69,23 @@ public sealed class SceneryWeather
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)
{
FogStart = Math.Clamp(start, FogMin, FogMax);

View File

@@ -1,5 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
public static class SceneryWeatherWriter
{
private static readonly Regex ManagedConfigLine = new(
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
private const string WordStart = @"(?<![^\s;])";
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)
{
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
string carried = CarriedConfigLines(text);
string stripped = text;
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
foreach (var pattern in Replaced)
stripped = Cut(stripped, pattern);
var inv = CultureInfo.InvariantCulture;
string config =
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
}
}
/// <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)
{
int colon = hhmm.IndexOf(':');
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
: "0" + hhmm[colon..];
}
/// <summary>The settings of every config block but those the starter writes itself.</summary>
private static string CarriedConfigLines(string text)
{
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
if (!block.Success)
return "";
var sb = new StringBuilder();
foreach (string raw in block.Groups[1].Value.Split('\n'))
{
string line = raw.TrimEnd('\r').Trim();
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
continue;
sb.Append(line).Append("\r\n");
}
foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _)))
foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n'))
{
string line = raw.Trim();
if (line.Length > 0)
sb.Append(line).Append("\r\n");
}
return sb.ToString();
}
}

View 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];
});
}

View File

@@ -5,7 +5,6 @@ using System.Linq;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
@@ -21,58 +20,97 @@ public sealed class SceneryParser
_random = random;
}
public Scenery Parse(string path, string content)
/// <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;
var faults = new List<string>();
var attachments = ParseAttachments(original);
// A file put in place of its include has directives of its own, for itself.
string own = SceneryIncludes.WithoutInlined(original);
var attachments = ParseAttachments(own);
var includes = new List<SceneryInclude>();
content = StripOptionalIncludes(content, includes);
var trainsetEntries = new List<string>();
content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b",
match =>
{
trainsetEntries.Add(match.Value);
return $"{{{{{trainsetEntries.Count - 1}}}}}";
},
RegexOptions.IgnoreCase);
// Read from the text with its optional includes blanked, which keeps every
// place where it was, so a fault's line is the line in the file.
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude);
var faults = syntax.Faults.ToList();
var inlined = SceneryIncludes.Ranges(content);
var trainsets = new List<Trainset>();
var looseVehicles = new List<Dynamic>();
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"),
Name = MatchDirective(original, "n"),
Description = MatchAllDirectives(original, "d"),
ImageName = MatchDirective(original, "i"),
Archival = MatchDirective(original, "a") != null
var trainset = new Trainset(content[start..end]) { SourceFile = SceneryIncludes.FileAt(inlined, start) };
if (!trainset.Parsed && !faults.Any(f => f.Offset >= start && f.Offset < end))
faults.Add(syntax.Lines.Fault(SceneryFaultKind.TrainsetUnreadable, start, trainset.Name));
cuts.Add((start, end, $"{{{{{trainsets.Count}}}}}"));
trainsets.Add(trainset);
}
foreach (var (start, end) in syntax.LooseVehicles)
{
// Included files' loose vehicles - road traffic, mostly - stay where they
// are as written, only their names are noted.
var vehicle = ReadLooseVehicle(content[start..end]);
looseNames.Add(vehicle?.Name ?? LooseName(content[start..end]));
if (SceneryIncludes.FileAt(inlined, start) != null)
continue;
if (vehicle is null)
{
if (!faults.Any(f => f.Offset >= start && f.Offset < end))
faults.Add(syntax.Lines.Fault(SceneryFaultKind.VehicleUnreadable, start, LooseName(content[start..end])));
continue;
}
looseVehicles.Add(vehicle);
cuts.Add((start, end, "\n"));
}
var scenery = new Scenery(path, Cut(content, cuts))
{
Group = MatchDirective(own, "l"),
Name = MatchDirective(own, "n"),
Description = MatchAllDirectives(own, "d"),
ImageName = MatchDirective(own, "i"),
Archival = MatchDirective(own, "a") != null
};
scenery.Attachments.AddRange(attachments);
scenery.Includes.AddRange(includes);
scenery.Trainsets.AddRange(trainsets);
scenery.LooseVehicles.AddRange(looseVehicles);
scenery.LooseVehicleNames.AddRange(looseNames);
scenery.Faults.AddRange(faults);
scenery.Faults.AddRange(faults.OrderBy(f => f.Offset));
ParseWeather(original, scenery.Weather);
// Without its comments: what is read here is what the writer later puts down as active.
ParseWeather(SceneryCode.Mask(original, out _), scenery.Weather);
scenery.Weather.AcceptAsAuthored();
foreach (string entry in trainsetEntries)
{
var trainset = new Trainset(entry);
scenery.Trainsets.Add(trainset);
if (!trainset.Parsed)
scenery.Faults.Add($"# trainset parse fault: {trainset.Name} ({trainset.Track})");
}
return scenery;
}
private static string Cut(string text, List<(int Start, int End, string With)> cuts)
{
var sb = new System.Text.StringBuilder(text.Length);
int at = 0;
foreach (var (start, end, with) in cuts.OrderBy(c => c.Start))
{
sb.Append(text, at, start - at).Append(with);
at = end;
}
return sb.Append(text, at, text.Length - at).ToString();
}
private static List<SceneryAttachment> ParseAttachments(string content)
{
var attachments = new List<SceneryAttachment>();
@@ -115,7 +153,7 @@ public sealed class SceneryParser
return Regex.Replace(
content,
@"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
@"include[ \t]+(\S+(?:[ \t]+\S+)*?)[ \t]+end[ \t]*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
m =>
{
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
@@ -137,65 +175,55 @@ public sealed class SceneryParser
Kind = kind,
Selected = kind == 1
});
return " ";
return Regex.Replace(m.Value, @"[^\r\n]", " ");
},
RegexOptions.IgnoreCase);
}
private static string ExtractLooseVehicles(string content, List<Dynamic> vehicles, List<string> names,
List<string> faults)
private static readonly Regex LooseNode = new(
@"(?is)^node\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic\b)\S+)*)\s*enddynamic$");
private static string LooseName(string node) =>
SceneryCode.Tokens(SceneryCode.Mask(node, out _)).ElementAtOrDefault(3).Text ?? "";
/// <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 m = LooseNode.Match(SceneryCode.Mask(node, out _));
if (!m.Success)
return null;
return Regex.Replace(
content,
@"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b",
m =>
try
{
var vehicle = new Dynamic
{
string name = m.Groups[3].Value;
RangeMax = float.Parse(m.Groups[1].Value, inv),
RangeMin = float.Parse(m.Groups[2].Value, inv),
Name = m.Groups[3].Value,
DataFolder = m.Groups[4].Value,
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
MmdFile = m.Groups[6].Value,
PathName = m.Groups[7].Value,
Offset = float.Parse(m.Groups[8].Value, inv),
DriverType = Dynamic.ParseDriver(m.Groups[9].Value),
HasVelocity = true,
Velocity = float.Parse(m.Groups[10].Value, inv)
};
if (name.StartsWith("{{", StringComparison.Ordinal))
return m.Value;
try
{
var vehicle = new Dynamic
{
RangeMax = float.Parse(m.Groups[1].Value, inv),
RangeMin = float.Parse(m.Groups[2].Value, inv),
Name = name,
DataFolder = m.Groups[4].Value,
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
MmdFile = m.Groups[6].Value,
PathName = m.Groups[7].Value,
Offset = float.Parse(m.Groups[8].Value, inv),
DriverType = m.Groups[9].Value.ToLowerInvariant() switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
},
HasVelocity = true,
Velocity = float.Parse(m.Groups[10].Value, inv)
};
var trailing = m.Groups[11].Value
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.ToList();
vehicle.ReadTrailing(trailing);
vehicles.Add(vehicle);
names.Add(vehicle.Name);
return "\n";
}
catch
{
faults.Add($"# loose dynamic parse fault: {name}");
names.Add(name);
return m.Value;
}
});
vehicle.ReadTrailing(m.Groups[11].Value
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.ToList());
return vehicle;
}
catch (FormatException)
{
return null;
}
catch (OverflowException)
{
return null;
}
}
private void ParseWeather(string content, SceneryWeather weather)

View File

@@ -3,10 +3,10 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StarterNG.Application.Abstractions;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
@@ -21,6 +21,9 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly IDiagnosticsLog _log;
private readonly SceneryParser _parser;
/// <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)
{
_files = files;
@@ -31,6 +34,7 @@ public sealed class SceneryRepository : ISceneryRepository
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
{
_listings.Clear();
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag<Scenery>();
int total = Math.Max(1, files.Count);
@@ -40,7 +44,7 @@ public sealed class SceneryRepository : ISceneryRepository
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
var scenery = Load(file);
var scenery = LoadOne(file);
if (scenery is not null)
loaded.Add(scenery);
@@ -52,14 +56,44 @@ public sealed class SceneryRepository : ISceneryRepository
}
public Scenery? Load(string path)
{
_listings.Clear();
return LoadOne(path);
}
public IReadOnlyList<string>? ListFiles() =>
_files.DirectoryExists(_paths.Scenery) ? SceneryFiles().ToList() : null;
private Scenery? LoadOne(string path)
{
try
{
if (!_files.FileExists(path))
return null;
var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path)));
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
// Looked at ahead of the reading: changed meanwhile, it counts as changed.
var sources = new Dictionary<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;
}
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() =>
_files.GetFiles(_paths.Scenery, "*.scn")
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);

View File

@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Infrastructure.Sceneries;
@@ -10,6 +12,10 @@ public sealed class TimetableLocator
{
private const string NoTimetable = "none";
// The heading of a timetable: [ Rodzaj i numer pociagu | EIE1531 ]
private static readonly Regex NumberLine = new(@"^\s*\[\s*Rodzaj\b[^|\r\n]*\|([^\]\r\n]+)",
RegexOptions.Multiline | RegexOptions.IgnoreCase);
private readonly IFileSystem _files;
public TimetableLocator(IFileSystem files)
@@ -36,4 +42,22 @@ public sealed class TimetableLocator
return candidates.FirstOrDefault(_files.FileExists);
}
/// <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;
}
}
}

View File

@@ -353,7 +353,7 @@ public partial class Scenarios : UserControl
private void PopulateFaults(Scenery scenery)
{
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;
foreach (var ts in scenery.Trainsets)