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