diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs index fbb39a3..3f4a791 100644 --- a/StarterNG.Tests/Fakes/TestInstallation.cs +++ b/StarterNG.Tests/Fakes/TestInstallation.cs @@ -37,6 +37,7 @@ public sealed class TestInstallation Executables = new ExecutableLocator(Files, Paths, Environment, Log); SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables); + Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning); } public InMemoryFileSystem Files { get; } @@ -57,6 +58,8 @@ public sealed class TestInstallation public SettingsPaths SettingsPaths { get; } public ExecutableLocator Executables { get; } public SettingsStore SettingsStore { get; } + public HaslerTapes Tapes { get; } + public bool SimulatorRunning { get; set; } /// Path of a file inside the fake installation. public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray()); diff --git a/StarterNG.Tests/HaslerTapeTests.cs b/StarterNG.Tests/HaslerTapeTests.cs new file mode 100644 index 0000000..6b752b9 --- /dev/null +++ b/StarterNG.Tests/HaslerTapeTests.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using StarterNG.Domain.Tapes; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class HaslerTapeTests +{ + private const string Header = + "Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " + + "BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " + + "Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n"; + + private static string Line(double t, double v, double brake, double current, string command = "none", + int security = 0) => + string.Create(CultureInfo.InvariantCulture, + $"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") + + $"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n"; + + [Fact] + public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks() + { + var tape = HaslerTape.Parse(Header + + Line(10, 0, 0.45, 0) + + Line(20, 36, 0, 400) + + Line(30, 72, 0, 0, security: 1)); + + Assert.Equal(3, tape.Samples.Count); + Assert.Equal(72, tape.MaxSpeedKmh, 6); + Assert.Equal(20, tape.Duration, 6); + Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each + Assert.True(tape.Samples[0].Braking); + Assert.True(tape.Samples[1].Current); + Assert.True(tape.Samples[2].SecurityAlarm); + } + + [Fact] + public void Standing_and_running_time_split_the_recording_like_the_old_analyser() + { + var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400) + + Line(180, 36, 0, 0)); + + Assert.Equal(60, tape.StandingTime, 6); + Assert.Equal(120, tape.RunningTime, 6); + Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it + Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6); + } + + [Fact] + public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says() + { + var tape = HaslerTape.Parse(Header + + "107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " + + "0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n"); + + var s = Assert.Single(tape.Samples); + Assert.Equal(38.6099, s.SpeedKmh, 6); + Assert.Equal(4.9981, s.PipePressBar, 6); + Assert.False(s.Braking); + } + + [Fact] + public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape() + { + var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0"); + + Assert.Equal(2, tape.Samples.Count); + Assert.Equal(5, tape.DistanceM, 6); + } + + [Fact] + public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance() + { + var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0)); + + Assert.Equal(1, tape.IndexAt(1.5, byDistance: false)); + Assert.Equal(2, tape.IndexAt(25, byDistance: true)); + Assert.Equal(0, tape.IndexAt(-3, byDistance: false)); + } + + [Theory] + [InlineData(0, 90)] + [InlineData(90, 90)] + [InlineData(90.5, 100)] + [InlineData(120, 120)] + [InlineData(151, 180)] + [InlineData(180, 180)] + [InlineData(181, 200)] + [InlineData(205, 220)] + public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) => + Assert.Equal(top, TapeFigures.TopSpeed(max)); + + [Theory] + [InlineData(1, 10)] + [InlineData(0.8, 10)] + [InlineData(0.79, 20)] + [InlineData(0.4, 20)] + [InlineData(0.16, 50)] + [InlineData(0.08, 100)] + [InlineData(0.04, 200)] + [InlineData(0.039, 500)] + public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) => + Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh)); + + [Theory] + [InlineData(60, 1)] + [InlineData(59, 2)] + [InlineData(30, 2)] + [InlineData(12, 5)] + [InlineData(6, 10)] + [InlineData(4, 15)] + [InlineData(2, 30)] + [InlineData(1, 60)] + [InlineData(0.99, 120)] + public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) => + Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute)); + + [Theory] + [InlineData(600, 0.1)] + [InlineData(500, 0.1)] + [InlineData(499, 0.2)] + [InlineData(100, 0.5)] + [InlineData(50, 1)] + [InlineData(25, 2)] + [InlineData(10, 5)] + [InlineData(5, 10)] + [InlineData(2.5, 20)] + [InlineData(2, 50)] + public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) => + Assert.Equal(step, TapeFigures.KmStep(pxPerKm)); + + [Theory] + [InlineData(-5, "0:00")] + [InlineData(0, "0:00")] + [InlineData(65, "1:05")] + [InlineData(3599, "59:59")] + [InlineData(3600, "1:00:00")] + [InlineData(3723, "1:02:03")] + [InlineData(90000, "25:00:00")] + public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) => + Assert.Equal(text, TapeFigures.Clock(seconds)); + + [Theory] + [InlineData("7:52", 7, 52, 0)] + [InlineData("07:52", 7, 52, 0)] + [InlineData(" 23:05 ", 23, 5, 0)] + [InlineData("7:52:30", 7, 52, 30)] + [InlineData("24:00", 0, 0, 0)] + [InlineData("noon", 0, 0, 0)] + [InlineData("", 0, 0, 0)] + [InlineData(null, 0, 0, 0)] + public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) => + Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text)); + + [Fact] + public void Tapes_are_listed_from_physicslog_newest_first() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0))); + var tapes = installation.Tapes; + + var list = tapes.List(); + + Assert.Single(list); + Assert.Equal("EU07-424", list[0].Vehicle); + Assert.Single(tapes.Load(list[0])!.Samples); + } +} diff --git a/StarterNG.Tests/TapeLaunchTests.cs b/StarterNG.Tests/TapeLaunchTests.cs new file mode 100644 index 0000000..a1285fa --- /dev/null +++ b/StarterNG.Tests/TapeLaunchTests.cs @@ -0,0 +1,485 @@ +using StarterNG.Application; +using StarterNG.Domain.Tapes; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class TapeLaunchTests +{ + private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc); + + [Fact] + public void A_launch_is_written_and_read_back_with_names_that_hold_spaces() + { + var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[] + { + new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"), + new TapeVehicle("wagon 1", "pkp/111a_v2", "111a") + }); + + var back = TapeLaunch.Parse(launch.Format())!; + + Assert.Equal(Launched, back.LaunchedUtc); + Assert.Equal("td.scn", back.Scenery); + Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock); + Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder); + Assert.Equal("111a", back.Find("wagon 1")!.TypeName); + } + + [Fact] + public void The_driven_vehicle_and_the_consist_are_written_and_read_back() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), + "EP07 424", new[] { "EP07 424", "wagon 1" }); + + var back = TapeLaunch.Parse(launch.Format())!; + + Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424")); + Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1")); + Assert.Equal(TapeRole.Other, back.RoleOf("et22-100")); + } + + [Fact] + public void A_note_without_the_driven_vehicle_tells_nothing_of_it() + { + var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!; + + Assert.Null(back.Driven); + Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424")); + Assert.Null(back.SceneryName); + Assert.Null(back.Train); + } + + [Fact] + public void The_scenario_and_the_train_are_written_and_read_back() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), + SceneryName: "Tor doświadczalny", Train: "EIE 1531"); + + var back = TapeLaunch.Parse(launch.Format())!; + + Assert.Equal("Tor doświadczalny", back.SceneryName); + Assert.Equal("EIE 1531", back.Train); + } + + [Fact] + public void A_tape_of_the_last_launch_gets_its_scenario_and_train() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t")); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "EP07-424", new[] { "EP07-424" }, + "Tor doświadczalny", "EIE1531"); + var context = tapes.ContextOf(tapes.List().Single()); + + Assert.Equal("Tor doświadczalny", context.Scenery); + Assert.Equal("EIE1531", context.Train); + } + + [Fact] + public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t")); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "EP07-424", new[] { "EP07-424" }, + "Tor doświadczalny", "EIE1531"); + var context = tapes.ContextOf(tapes.List().Single()); + + Assert.Equal("Tor doświadczalny", context.Scenery); + Assert.Null(context.Train); + } + + [Fact] + public void A_scenario_without_a_name_goes_by_its_file() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t")); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty()); + + Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery); + } + + [Theory] + [InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")] + [InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")] + [InlineData("[ Kategoria | IC ]\r\n", null)] + [InlineData("", null)] + public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number) + { + var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable); + + Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files) + .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), + "baltyk/eie1531")); + } + + [Theory] + [InlineData("none")] + [InlineData("NONE")] + [InlineData("")] + [InlineData(null)] + public void A_trainset_without_a_timetable_has_no_train_number(string? name) => + Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem()) + .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name)); + + [Fact] + public void A_timetable_that_is_not_there_gives_no_train_number() => + Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem()) + .TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), + @"baltyk\mod\eie1531")); + + [Fact] + public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t")); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "c-lok", new[] { "c-lok", "b-wagon" }); + + Assert.Equal("c-lok", tapes.DrivenVehicle); + Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle)); + Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role)); + } + + [Fact] + public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }); + + Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown)); + Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown)); + } + + [Fact] + public void Where_the_player_has_been_is_written_and_read_back() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), + Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n")); + + var back = TapeLaunch.Parse(launch.Format())!; + + Assert.Equal("b", back.Player!.Current); + Assert.True(back.Player.Used("a")); + } + + [Fact] + public void A_note_of_a_single_launch_still_reads() + { + var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n"); + + Assert.Equal("td.scn", all.Single().Scenery); + Assert.Null(all.Single().Player); + } + + [Fact] + public void Tapes_part_between_the_launches_they_were_written_after() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "ra.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "rb.dat"), "t"); + var installation = new TestInstallation(files); + var tapes = installation.Tapes; + installation.Clock.Now = Launched; + tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty(), "ra", new[] { "ra" }, "A", "T1"); + installation.Clock.Now = Launched.AddHours(1); + tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty(), "rb", new[] { "rb" }, "B", "T2"); + files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30)) + .WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90)); + + var sessions = tapes.Sessions(); + + Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName)); + Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle); + Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle); + Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock); + Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train); + } + + [Fact] + public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written() + { + var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "a.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "b.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "c.dat"), "t") + .WrittenAt(TestInstallation.At("physicslog", "a.dat"), early) + .WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30)) + .WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1))); + + var sessions = installation.Tapes.Sessions(); + + Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count)); + Assert.All(sessions, session => Assert.Null(session.Launch)); + } + + [Fact] + public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator() + { + var installation = new TestInstallation(new InMemoryFileSystem()); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra"); + + installation.SimulatorRunning = true; + var running = tapes.Sessions().Single(); + Assert.True(running.Running); + Assert.Empty(running.Tapes); + + installation.SimulatorRunning = false; + Assert.Empty(tapes.Sessions()); + } + + [Fact] + public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale() + { + string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt"); + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n") + .WithFile(kept, "rb received command: [x]\n") + .WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2)) + .WrittenAt(kept, Launched.AddMinutes(10))); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }); + + Assert.Equal("rb", tapes.DrivenVehicle); + } + + [Fact] + public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins() + { + var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n"); + var written = Launched.AddMinutes(30); + installation.Files.WrittenAt(TestInstallation.At("log.txt"), written) + .WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written) + .WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written) + .WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written); + + installation.Clock.Now = Launched.AddHours(1); + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "inny", new[] { "inny" }); + + var run = tapes.Sessions().Single(); + Assert.Equal("Test", run.Launch!.SceneryName); + Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role); + Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role); + } + + [Fact] + public void Only_the_last_launches_are_remembered() + { + var installation = new TestInstallation(new InMemoryFileSystem()); + var tapes = installation.Tapes; + for (int i = 0; i < 35; i++) + { + installation.Clock.Now = Launched.AddMinutes(i); + tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty()); + } + + var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText( + TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8)); + + Assert.Equal(30, noted.Count); + Assert.Equal("s5.scn", noted[0].Scenery); + Assert.Equal("s34.scn", noted[^1].Scenery); + } + + [Fact] + public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in() + { + var player = PlayerVehicles.None.After( + "[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" + + "[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" + + "[ 255.539 ]\t\tKey pressed: [F5]\r\n" + + "[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" + + "lok-c received command: [cabchangebackward]\n"); + + Assert.Equal("lok-c", player.Current); + Assert.True(player.Used("LOK-A")); + Assert.True(player.Used("lok b")); + Assert.False(player.Used("lok-d")); + } + + [Fact] + public void Going_back_to_a_vehicle_makes_it_the_current_one_again() + { + var player = PlayerVehicles.None + .After("a received command: [x]\nb received command: [x]\n") + .After("A received command: [x]\n"); + + Assert.Equal("A", player.Current); + Assert.True(player.Used("b")); + } + + [Fact] + public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), + "ra", new[] { "ra", "s", "rb" }); + var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n"); + + Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player)); + Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player)); + Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player)); + Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player)); + } + + [Fact] + public void Before_the_first_command_the_vehicle_of_the_launch_is_driven() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }); + + Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None)); + Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None)); + } + + private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log) + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "ra.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "rb.dat"), "t") + .WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t") + .WithFile(TestInstallation.At("log.txt"), log)); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1"); + return (tapes, installation); + } + + [Fact] + public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train() + { + var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n"); + + Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle)); + Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role)); + Assert.Equal("rb", tapes.DrivenVehicle); + Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train); + } + + [Fact] + public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first() + { + var (tapes, _) = TapesOfTrain("obcy received command: [x]\n"); + + var driven = tapes.List().First(); + + Assert.Equal("obcy", driven.Vehicle); + Assert.Null(tapes.ContextOf(driven).Train); + } + + [Fact] + public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look() + { + const string start = "ra received command: [x]\n"; + var (tapes, installation) = TapesOfTrain(start); + Assert.Equal("ra", tapes.DrivenVehicle); + + installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com"); + Assert.Equal("ra", tapes.DrivenVehicle); + + installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n"); + Assert.Equal("rb", tapes.DrivenVehicle); + } + + [Fact] + public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one() + { + var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n"); + Assert.Equal("rb", tapes.DrivenVehicle); + + installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n"); + + Assert.Equal("obcy", tapes.DrivenVehicle); + Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role); + } + + [Fact] + public void A_log_older_than_the_launch_says_nothing_of_it() + { + var (tapes, installation) = TapesOfTrain("rb received command: [x]\n"); + installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10); + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra", "rb" }); + + Assert.Equal("ra", tapes.DrivenVehicle); + } + + [Fact] + public void A_tape_older_than_the_last_launch_has_no_part_in_it() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t")); + installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1); + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "c-lok"); + + Assert.Equal(TapeRole.Other, tapes.List().Single().Role); + } + + [Fact] + public void A_log_written_before_the_launch_is_not_taken_for_one_of_it() + { + var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty()); + + Assert.True(launch.Covers(Launched.AddMinutes(3))); + Assert.False(launch.Covers(Launched.AddHours(-1))); + } + + [Theory] + [InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)] + [InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)] + [InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)] + [InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)] + public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) => + Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok")); + + [Fact] + public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by() + { + Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001")); + Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193")); + } + + [Fact] + public void A_tape_of_the_last_launch_gets_its_clock_and_recorder() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t")); + installation.Clock.Now = Launched; + var tapes = installation.Tapes; + + tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0), + new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") }); + var context = tapes.ContextOf(tapes.List().Single()); + + Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock); + Assert.Equal(RecorderKind.Electronic, context.Recorder); + } + + [Fact] + public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t")); + installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1); + var tapes = installation.Tapes; + + tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty()); + + Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock); + } +} diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs new file mode 100644 index 0000000..a68731d --- /dev/null +++ b/StarterNG/Application/HaslerTapes.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Tapes; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; + +namespace StarterNG.Application; + +public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size, + TapeRole Role = TapeRole.Other); + +/// One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too. +public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running, + IReadOnlyList Tapes); + +/// What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train. +public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder, + string? Scenery = null, string? Train = null); + +/// The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run. +public sealed class HaslerTapes +{ + private const int RememberedLaunches = 30; + private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2); + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly IClock _clock; + private readonly TimetableLocator _timetables; + private readonly Func _simulatorRunning; + private readonly object _playerLock = new(); + private IReadOnlyList? _launches; + private (DateTime Written, long Size) _launchesAt; + private PlayerVehicles _player = PlayerVehicles.None; + private DateTime _playerLaunch; + private string? _playerLog; + private long _playerLogRead; + + public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock, + TimetableLocator timetables, Func simulatorRunning) + { + _files = files; + _paths = paths; + _log = log; + _clock = clock; + _timetables = timetables; + _simulatorRunning = simulatorRunning; + } + + public string Folder => _paths.FromRoot("physicslog"); + + /// Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one. + private string LaunchFile => Path.Combine(Folder, "starter-launch.txt"); + + /// + /// The simulator logs every driven vehicle under its scenery name, with the + /// time counted from the start; the clock it starts at and what each name is + /// are only known here. So is whose tape is the player's: a scenery with no + /// consist picked starts with the free camera, in no vehicle at all. + /// + public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven) + { + var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm", + CultureInfo.InvariantCulture, out var over) + ? over + : TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time) + ? time + : TimeSpan.Zero; + + var own = trainset?.Vehicles ?? new List(); + var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own) + .Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? "")); + RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven, + driven is null ? null : own.Select(v => v.Name), scenery.Name, + driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name)); + } + + /// Notes down, next to the logs the simulator is about to write, what only the starter knows. + public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable vehicles, + string? driven = null, IEnumerable? consist = null, + string? sceneryName = null, string? train = null) + { + var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock, + vehicles.Where(v => !string.IsNullOrEmpty(v.Name)) + .DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase) + .ToList(), + driven, consist?.ToList(), sceneryName, train); + try + { + var noted = Launches().ToList(); + // The log is about to begin anew: what it says of the run before stays with that run. + if (noted.Count > 0) + noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) }; + noted.Add(launch); + + _files.CreateDirectory(Folder); + _files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())), + Encoding.UTF8); + lock (_playerLock) + _launches = null; + } + catch (Exception ex) + { + _log.Log($"writing {LaunchFile}", ex); + } + } + + /// + /// The launches noted so far, oldest first. Read again only when the file has + /// changed: this runs every few seconds next to a running simulator. + /// + private IReadOnlyList Launches() + { + lock (_playerLock) + { + try + { + if (!_files.FileExists(LaunchFile)) + return Array.Empty(); + + var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile)); + if (_launches is null || at != _launchesAt) + { + _launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8)); + _launchesAt = at; + } + return _launches; + } + catch (Exception ex) + { + _log.Log($"reading {LaunchFile}", ex); + return Array.Empty(); + } + } + } + + /// The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/. + private string? LogOf(TapeLaunch launch) + { + var logs = new List { _paths.FromRoot("log.txt") }; + string kept = _paths.FromRoot("logs"); + if (_files.DirectoryExists(kept)) + logs.AddRange(_files.GetFiles(kept, "log (*.txt")); + + return logs.Where(_files.FileExists) + .Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log))) + .OrderByDescending(_files.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + /// + /// Where the player has been in this launch. Only what the log has gained since + /// the last look is read: this runs every few seconds next to a running simulator. + /// + private PlayerVehicles PlayerIn(TapeLaunch launch) + { + lock (_playerLock) + { + string? path = null; + try + { + path = LogOf(launch); + if (path is null) + return PlayerVehicles.Unknown; + + long size = _files.GetFileSize(path); + if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead) + { + _player = PlayerVehicles.None; + _playerLaunch = launch.LaunchedUtc; + _playerLog = path; + _playerLogRead = 0; + } + if (size == _playerLogRead) + return _player; + + using var stream = _files.OpenRead(path); + stream.Seek(_playerLogRead, SeekOrigin.Begin); + using var added = new MemoryStream(); + stream.CopyTo(added); + byte[] bytes = added.ToArray(); + + // A line still being written waits for the next look. + int end = Array.LastIndexOf(bytes, (byte)'\n') + 1; + if (end > 0) + { + _player = _player.After(LegacyText.Decode(bytes[..end])); + _playerLogRead += end; + } + } + catch (Exception ex) + { + _log.Log($"reading {path}", ex); + } + return _player; + } + } + + /// The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one. + public string? DrivenVehicle => + Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null; + + public TapeContext ContextOf(TapeFile file) + { + var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc)); + if (launch is null) + return new TapeContext(null, Recorders.Guess(null, file.Vehicle)); + + var vehicle = launch.Find(file.Vehicle); + string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName; + return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle), + string.IsNullOrWhiteSpace(scenery) ? null : scenery, + launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train); + } + + /// Every tape, session by session. + public IReadOnlyList List() => Sessions().SelectMany(session => session.Tapes).ToList(); + + /// + /// The runs the tapes came from, the running or latest one first. A tape belongs + /// to the last launch before it was written; one written before any known launch + /// goes with those written about the same time, as a run writes them all at its end. + /// + public IReadOnlyList Sessions() + { + try + { + var launches = Launches(); + var tapes = _files.DirectoryExists(Folder) + ? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList() + : new List(); + var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc))) + .Where(group => group.Key is not null) + .ToDictionary(group => group.Key!, group => group.ToList()); + + var sessions = new List(); + foreach (var launch in launches) + { + bool last = ReferenceEquals(launch, launches[^1]); + bool running = last && _simulatorRunning(); + if (!running && !behind.ContainsKey(launch)) + continue; + + var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown; + sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List()) + .Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }))); + } + + var run = new List(); + foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc)) + { + if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns) + { + sessions.Add(Session(null, running: false, run)); + run = new List(); + } + run.Add(tape); + } + if (run.Count > 0) + sessions.Add(Session(null, running: false, run)); + + return sessions.OrderByDescending(session => session.Running) + .ThenByDescending(session => session.LastWrittenUtc) + .ToList(); + } + catch (Exception ex) + { + _log.Log($"listing {Folder}", ex); + return Array.Empty(); + } + } + + private TapeFile ReadTape(string path) => + new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path)); + + /// The player's own tapes first: every manned vehicle leaves one. + private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable tapes) + { + var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList(); + var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc); + return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started, + running, listed); + } + + public HaslerTape? Load(TapeFile file) + { + try + { + // Through OpenRead, which does not mind the simulator still writing the file. + using var stream = _files.OpenRead(file.Path); + using var reader = new StreamReader(stream, Encoding.ASCII); + return HaslerTape.Parse(reader.ReadToEnd()); + } + catch (Exception ex) + { + _log.Log($"reading {file.Path}", ex); + return null; + } + } +} diff --git a/StarterNG/Domain/Tapes/HaslerTape.cs b/StarterNG/Domain/Tapes/HaslerTape.cs new file mode 100644 index 0000000..ec11775 --- /dev/null +++ b/StarterNG/Domain/Tapes/HaslerTape.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace StarterNG.Domain.Tapes; + +/// One line of the simulator's physics log, in the units a speed recorder shows. +public readonly record struct TapeSample( + double Time, + double SpeedKmh, + double DistanceM, + double BrakePressBar, + double PipePressBar, + double MotorCurrentA, + bool SecurityAlarm) +{ + /// Above this cylinder pressure the sample counts as braking. + public const double BrakeMarkPressBar = 0.4; + + public bool Braking => BrakePressBar > BrakeMarkPressBar; + + public bool Current => MotorCurrentA != 0.0; +} + +/// +/// A trip recorded by the simulator with "physicslog yes": physicslog/<vehicle>.dat, +/// a line every 0.05 s of simulated time, read as a Hasler speed recorder tape. +/// +public sealed class HaslerTape +{ + // Columns counted from the start; the security flag is counted from the end, past + // the command, which is free text. The header says m/s and MPa, the values are km/h and bar. + private const int TimeColumn = 0; + private const int VelocityColumn = 1; + private const int BrakePressColumn = 8; + private const int PipePressColumn = 9; + private const int MotorCurrentColumn = 10; + private const int SecurityFromEnd = 6; + private const int MinimumColumns = 17; + + public HaslerTape(IReadOnlyList samples) + { + Samples = samples; + foreach (var s in samples) + MaxSpeedKmh = Math.Max(MaxSpeedKmh, s.SpeedKmh); + for (int i = 1; i < samples.Count; i++) + if (samples[i].SpeedKmh > 0.5) + RunningTime += samples[i].Time - samples[i - 1].Time; + } + + public IReadOnlyList Samples { get; } + + public bool IsEmpty => Samples.Count == 0; + + public double StartTime => IsEmpty ? 0 : Samples[0].Time; + + public double Duration => IsEmpty ? 0 : Samples[^1].Time - Samples[0].Time; + + public double DistanceM => IsEmpty ? 0 : Samples[^1].DistanceM; + + public double MaxSpeedKmh { get; } + + /// Seconds on the move; the rest of was spent standing. + public double RunningTime { get; } + + public double StandingTime => Duration - RunningTime; + + /// Mean speed while moving. + public double AverageSpeedKmh => RunningTime > 0 ? DistanceM / RunningTime * 3.6 : 0; + + /// Mean speed over the whole recording, stops included - the commercial speed. + public double AverageWithStopsKmh => Duration > 0 ? DistanceM / Duration * 3.6 : 0; + + public static HaslerTape Parse(string text) + { + var samples = new List(); + double distance = 0; + TapeSample? previous = null; + + foreach (var raw in text.Split('\n')) + { + var columns = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (columns.Length < MinimumColumns + || !Number(columns[TimeColumn], out double time) + || !Number(columns[VelocityColumn], out double velocity)) + continue; // the header, or a line cut short by a simulator still writing + + Number(columns[BrakePressColumn], out double brake); + Number(columns[PipePressColumn], out double pipe); + Number(columns[MotorCurrentColumn], out double current); + bool security = columns[^SecurityFromEnd] == "1"; + + double speed = Math.Abs(velocity); + if (previous is { } p) + { + if (time < p.Time) + continue; + distance += (p.SpeedKmh + speed) / 2 / 3.6 * (time - p.Time); + } + + var sample = new TapeSample(time, speed, distance, brake, pipe, current, security); + samples.Add(sample); + previous = sample; + } + + return new HaslerTape(samples); + } + + /// Index of the last sample at or before the given time or distance. + public int IndexAt(double position, bool byDistance) + { + int lo = 0, hi = Samples.Count - 1; + if (hi < 0) + return -1; + while (lo < hi) + { + int mid = (lo + hi + 1) / 2; + double value = byDistance ? Samples[mid].DistanceM : Samples[mid].Time; + if (value <= position) + lo = mid; + else + hi = mid - 1; + } + return lo; + } + + private static bool Number(string text, out double value) => + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); +} diff --git a/StarterNG/Domain/Tapes/PlayerVehicles.cs b/StarterNG/Domain/Tapes/PlayerVehicles.cs new file mode 100644 index 0000000..9459f00 --- /dev/null +++ b/StarterNG/Domain/Tapes/PlayerVehicles.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StarterNG.Domain.Tapes; + +/// The vehicles the player's commands went to during a run, the one used last at the end. +public sealed class PlayerVehicles +{ + private const string CommandMark = " received command: ["; + + /// No log of the run to go by, so a change of vehicle cannot be seen. + public static readonly PlayerVehicles Unknown = new(Array.Empty(), known: false); + + /// A log with no command in it yet. + public static readonly PlayerVehicles None = new(Array.Empty(), known: true); + + private readonly IReadOnlyList _vehicles; + + private PlayerVehicles(IReadOnlyList vehicles, bool known) + { + _vehicles = vehicles; + Known = known; + } + + public bool Known { get; } + + public IReadOnlyList Names => _vehicles; + + public string? Current => _vehicles.Count > 0 ? _vehicles[^1] : null; + + public static PlayerVehicles Of(IEnumerable names) => new(names.ToList(), known: true); + + public bool Used(string vehicle) => _vehicles.Contains(vehicle, StringComparer.OrdinalIgnoreCase); + + /// What a further part of the simulator's log, in whole lines, adds to this. + public PlayerVehicles After(string log) + { + var vehicles = _vehicles.ToList(); + foreach (string line in log.Split('\n')) + { + int mark = line.IndexOf(CommandMark, StringComparison.Ordinal); + if (mark < 0) + continue; + + int stamp = line.IndexOf(']'); + string vehicle = line[(stamp < mark ? stamp + 1 : 0)..mark].Trim(); + if (vehicle.Length == 0 || string.Equals(vehicles.LastOrDefault(), vehicle, StringComparison.OrdinalIgnoreCase)) + continue; + + vehicles.RemoveAll(v => string.Equals(v, vehicle, StringComparison.OrdinalIgnoreCase)); + vehicles.Add(vehicle); + } + return new PlayerVehicles(vehicles, known: true); + } +} diff --git a/StarterNG/Domain/Tapes/TapeFigures.cs b/StarterNG/Domain/Tapes/TapeFigures.cs new file mode 100644 index 0000000..c20d4b4 --- /dev/null +++ b/StarterNG/Domain/Tapes/TapeFigures.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; + +namespace StarterNG.Domain.Tapes; + +/// The figures a tape is printed and read with, which no drawing is needed to work out. +public static class TapeFigures +{ + private static readonly double[] SpeedRanges = { 90, 100, 120, 150, 180 }; + private static readonly double[] SpeedSteps = { 10, 20, 50, 100, 200 }; + private static readonly double[] MinuteSteps = { 1, 2, 5, 10, 15, 30, 60 }; + private static readonly double[] KmSteps = { 0.1, 0.2, 0.5, 1, 2, 5, 10, 20 }; + + /// The scale is printed for the speedometer's range: 90, 100, 120, 150 or 180 km/h. + public static double TopSpeed(double maxKmh) + { + foreach (double range in SpeedRanges) + if (maxKmh <= range) + return range; + return Math.Ceiling(maxKmh / 20) * 20; + } + + /// A round number of km/h between value lines, far enough apart for their figures to stay readable. + public static int SpeedStep(double pxPerKmh) => (int)FirstThatFits(SpeedSteps, pxPerKmh, 8, 500); + + /// A round number of minutes between clock lines, about every 60 pixels or more. + public static double MinuteStep(double pxPerMinute) => FirstThatFits(MinuteSteps, pxPerMinute, 60, 120); + + /// A round number of kilometres between distance marks, 50 pixels apart or more. + public static double KmStep(double pxPerKm) => FirstThatFits(KmSteps, pxPerKm, 50, 50); + + private static double FirstThatFits(double[] steps, double pxPerUnit, double minPx, double otherwise) + { + foreach (double step in steps) + if (step * pxPerUnit >= minPx) + return step; + return otherwise; + } + + /// Hours only when there are any: 7:05, 1:02:03. Below zero counts as zero. + public static string Clock(double seconds) + { + var t = TimeSpan.FromSeconds(Math.Max(0, seconds)); + return t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}"; + } + + /// A time of day as typed; anything that is not one is midnight. + public static TimeSpan ParseClock(string? text) => + TimeSpan.TryParseExact(text?.Trim(), new[] { @"h\:mm", @"hh\:mm", @"h\:mm\:ss" }, CultureInfo.InvariantCulture, + out var clock) && clock < TimeSpan.FromDays(1) + ? clock + : TimeSpan.Zero; +} diff --git a/StarterNG/Domain/Tapes/TapeLaunch.cs b/StarterNG/Domain/Tapes/TapeLaunch.cs new file mode 100644 index 0000000..b29dcc0 --- /dev/null +++ b/StarterNG/Domain/Tapes/TapeLaunch.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace StarterNG.Domain.Tapes; + +/// How a vehicle records its runs: a Hasler-type paper tape, or an electronic recorder read out on a screen. +public enum RecorderKind +{ + Hasler, + Electronic +} + +/// A vehicle of a launched scenery, as the simulator names it in physicslog/. +public sealed record TapeVehicle(string Name, string DataFolder, string TypeName); + +/// What a tape is to the one who started the simulator; in the order they are listed by. +public enum TapeRole +{ + Other, + OwnConsist, + DrivenEarlier, + StartedIn, + Driven +} + +/// +/// What the starter knew when it launched the simulator and the log does not +/// say: the time of day the scenery starts at - the log only counts seconds from +/// there - which vehicle stands behind each name the logs are written under, +/// which of them the player went off in: every manned vehicle leaves a log - the +/// scenario by its name and the number of the train the timetable gives. Once +/// the next launch is noted, also where the player has been. +/// +public sealed record TapeLaunch(DateTime LaunchedUtc, string Scenery, TimeSpan StartClock, + IReadOnlyList Vehicles, string? Driven = null, + IReadOnlyList? Consist = null, string? SceneryName = null, + string? Train = null, PlayerVehicles? Player = null) +{ + private static readonly Regex NextLaunch = new(@"(?m)^(?=launched\t)", RegexOptions.Compiled); + + public TapeVehicle? Find(string vehicleName) => + Vehicles.FirstOrDefault(v => string.Equals(v.Name, vehicleName, StringComparison.OrdinalIgnoreCase)); + + public TapeRole RoleOf(string vehicleName) => + string.Equals(Driven, vehicleName, StringComparison.OrdinalIgnoreCase) + ? TapeRole.Driven + : Consist?.Contains(vehicleName, StringComparer.OrdinalIgnoreCase) == true + ? TapeRole.OwnConsist + : TapeRole.Other; + + /// The role as the run went: the vehicle the player used last is the driven one, whichever they set off in. + public TapeRole RoleOf(string vehicleName, PlayerVehicles player) + { + var role = RoleOf(vehicleName); + if (!player.Known) + return role == TapeRole.Driven ? TapeRole.StartedIn : role; + if (player.Current is not { } current) + return role; + if (string.Equals(current, vehicleName, StringComparison.OrdinalIgnoreCase)) + return TapeRole.Driven; + return role == TapeRole.Driven || player.Used(vehicleName) ? TapeRole.DrivenEarlier : role; + } + + /// + /// A log belongs to this launch if the simulator wrote it after the starter + /// started it; the simulator truncates the log of a vehicle on every run. + /// + public bool Covers(DateTime logWrittenUtc) => logWrittenUtc >= LaunchedUtc - TimeSpan.FromSeconds(5); + + // One fact a line, tab separated: names and folders may hold spaces. + public string Format() + { + var text = new StringBuilder(); + text.Append("launched\t").Append(LaunchedUtc.ToString("O", CultureInfo.InvariantCulture)).Append('\n'); + text.Append("scenery\t").Append(Scenery).Append('\n'); + text.Append("clock\t").Append(StartClock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)).Append('\n'); + if (!string.IsNullOrWhiteSpace(SceneryName)) + text.Append("name\t").Append(SceneryName).Append('\n'); + if (!string.IsNullOrWhiteSpace(Train)) + text.Append("train\t").Append(Train).Append('\n'); + foreach (var v in Vehicles) + text.Append("vehicle\t").Append(v.Name).Append('\t').Append(v.DataFolder).Append('\t').Append(v.TypeName) + .Append('\n'); + if (Driven is not null) + text.Append("driven\t").Append(Driven).Append('\n'); + foreach (string name in Consist ?? Array.Empty()) + text.Append("consist\t").Append(name).Append('\n'); + if (Player is { Known: true } player) + text.AppendJoin('\t', player.Names.Prepend("played")).Append('\n'); + return text.ToString(); + } + + /// Every launch a file holds, oldest first. + public static IReadOnlyList ParseAll(string text) => + NextLaunch.Split(text).Select(Parse).OfType().OrderBy(launch => launch.LaunchedUtc).ToList(); + + public static TapeLaunch? Parse(string text) + { + DateTime? launched = null; + string scenery = ""; + var clock = TimeSpan.Zero; + var vehicles = new List(); + string? driven = null; + var consist = new List(); + string? name = null; + string? train = null; + PlayerVehicles? player = null; + foreach (var line in text.Split('\n')) + { + var parts = line.TrimEnd('\r').Split('\t'); + switch (parts[0]) + { + case "launched" when parts.Length > 1 && + DateTime.TryParse(parts[1], CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var at): + launched = at.ToUniversalTime(); + break; + case "scenery" when parts.Length > 1: + scenery = parts[1]; + break; + case "clock" when parts.Length > 1: + TimeSpan.TryParseExact(parts[1], @"h\:mm", CultureInfo.InvariantCulture, out clock); + break; + case "vehicle" when parts.Length > 3: + vehicles.Add(new TapeVehicle(parts[1], parts[2], parts[3])); + break; + case "driven" when parts.Length > 1: + driven = parts[1]; + break; + case "consist" when parts.Length > 1: + consist.Add(parts[1]); + break; + case "name" when parts.Length > 1: + name = parts[1]; + break; + case "train" when parts.Length > 1: + train = parts[1]; + break; + case "played": + player = PlayerVehicles.Of(parts.Skip(1)); + break; + } + } + return launched is { } when + ? new TapeLaunch(when, scenery, clock, vehicles, driven, consist, name, train, player) + : null; + } +} + +public static class Recorders +{ + /// + /// Vehicles with an electronic recorder in place of a Hasler, matched on the vehicle + /// folder and type, or failing that on the name the log is written under. + /// + private static readonly Regex Electronic = new( + @"(?ix) (^|[^a-z0-9]) + ( en57a[lk][a-z]* | en71ak[a-z]* | en76 | en96 | en97 | ed74 | ed160 | ed161 | ed250 | ed78 | ed72a + | ep07p | eu07a | e6act | e6acti | e4dcu | e4mcu | e4msu | e6mcu | 111e + | x4e | traxx | vectron | griffin | dragon | husarz | elf | impuls | flirt + | pendolino | sa1[0-9][0-9] | 22we | 27we | 31we | 34we | 35we | 36we | 45we ) + ($|[^a-z0-9])", + RegexOptions.Compiled); + + public static RecorderKind Guess(TapeVehicle? vehicle, string tapeName) + { + if (vehicle is not null) + return Electronic.IsMatch(vehicle.DataFolder.Replace('\\', '/').Split('/').Last()) + || Electronic.IsMatch(vehicle.TypeName) + ? RecorderKind.Electronic + : RecorderKind.Hasler; + return Electronic.IsMatch(tapeName) ? RecorderKind.Electronic : RecorderKind.Hasler; + } +} diff --git a/StarterNG/Views/Tapes/ElectronicTapeView.cs b/StarterNG/Views/Tapes/ElectronicTapeView.cs new file mode 100644 index 0000000..f5bd4e2 --- /dev/null +++ b/StarterNG/Views/Tapes/ElectronicTapeView.cs @@ -0,0 +1,200 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Media; +using StarterNG.Domain.Tapes; + +namespace StarterNG.Views.Tapes; + +/// +/// A trip on the screen of an electronic recorder - no paper, no stylus, no +/// clockwork: the speed over a labelled km/h axis, the brake pipe and cylinder +/// pressures on a chart of their own, and the binary +/// channels - vigilance alarm, motor current, braking - as bars in rows below, +/// with the clock and the kilometres along the axes. +/// +public sealed class ElectronicTapeView : TapeView +{ + private const double AxisRow = 22; + private const double SpeedChart = 220; + private const double ChartGap = 14; + private const double PressureChart = 84; + private const double PressureFullScaleBar = 6; + private const double ChannelRow = 18; + private const int Channels = 3; + private const double LabelRepeat = 900; + + private const double SpeedTop = AxisRow; + private const double SpeedBottom = SpeedTop + SpeedChart; + private const double PressureTop = SpeedBottom + ChartGap; + private const double PressureBottom = PressureTop + PressureChart; + private const double ChannelsTop = PressureBottom + ChartGap; + private const double ChannelsBottom = ChannelsTop + Channels * ChannelRow; + + private static readonly IBrush Background = new SolidColorBrush(Color.Parse("#F4F6F8")); + private static readonly IBrush PlotBrush = Brushes.White; + private static readonly IBrush TextBrush = new SolidColorBrush(Color.Parse("#3A4450")); + private static readonly IBrush DimBrush = new SolidColorBrush(Color.Parse("#7B8794")); + private static readonly IPen FramePen = new Pen(new SolidColorBrush(Color.Parse("#B9C2CC")), 1); + private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.Parse("#E4E8ED")), 1); + private static readonly IPen GridBoldPen = new Pen(new SolidColorBrush(Color.Parse("#CED5DD")), 1); + private static readonly IPen SpeedPen = new Pen(new SolidColorBrush(Color.Parse("#1565C0")), 1.6, + lineJoin: PenLineJoin.Round); + private static readonly IPen PipePen = new Pen(new SolidColorBrush(Color.Parse("#2E7D32")), 1.3, + lineJoin: PenLineJoin.Round); + private static readonly IPen CylinderPen = new Pen(new SolidColorBrush(Color.Parse("#C62828")), 1.3, + lineJoin: PenLineJoin.Round); + private static readonly IBrush ShpBrush = new SolidColorBrush(Color.Parse("#F9A825")); + private static readonly IBrush CurrentBrush = new SolidColorBrush(Color.Parse("#5C6BC0")); + private static readonly IBrush BrakeBrush = new SolidColorBrush(Color.Parse("#E53935")); + private static readonly Typeface Face = new(FontFamily.Default); + + protected override double LeftMargin => 74; + + protected override double TapeHeight => ChannelsBottom + AxisRow + 4; + + private double SpeedY(double speed) => SpeedBottom - SpeedChart * Math.Min(speed, TopSpeed) / TopSpeed; + + private static double PressureY(double bar) => + PressureBottom - PressureChart * Math.Clamp(bar / PressureFullScaleBar, 0, 1); + + protected override void DrawBlank(DrawingContext context) => + context.DrawRectangle(Background, null, new Rect(0, 0, Bounds.Width, TapeHeight)); + + protected override void DrawTape(DrawingContext context, HaslerTape tape) + { + double left = LeftMargin, right = TapeRight; + foreach (var (top, bottom) in new[] { (SpeedTop, SpeedBottom), (PressureTop, PressureBottom), + (ChannelsTop, ChannelsBottom) }) + context.DrawRectangle(PlotBrush, FramePen, new Rect(left, top, right - left, bottom - top)); + + DrawValueGrid(context, left, right); + DrawTimeAxis(context, tape); + DrawDistanceAxis(context, tape); + + context.DrawGeometry(null, PipePen, Trace(tape, s => PressureY(s.PipePressBar))); + context.DrawGeometry(null, CylinderPen, Trace(tape, s => PressureY(s.BrakePressBar))); + context.DrawGeometry(null, SpeedPen, Trace(tape, s => SpeedY(s.SpeedKmh))); + + DrawChannel(context, tape, 0, s => s.SecurityAlarm, ShpBrush); + DrawChannel(context, tape, 1, s => s.Current, CurrentBrush); + DrawChannel(context, tape, 2, s => s.Braking, BrakeBrush); + + DrawLegend(context); + } + + /// The value lines and their figures, repeated along a long trip so a scrolled view still reads. + private void DrawValueGrid(DrawingContext context, double left, double right) + { + int step = TapeFigures.SpeedStep(SpeedChart / TopSpeed); + for (int v = 0; v <= TopSpeed; v += step) + { + double y = SpeedY(v); + context.DrawLine(v % (5 * step) == 0 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y)); + if (v % (2 * step) == 0) + for (double x = left; x < right; x += LabelRepeat) + Print(context, v.ToString(CultureInfo.InvariantCulture), x == left ? 30 : x + 4, y - 7, 10, + x == left ? TextBrush : DimBrush); + } + + for (int bar = 0; bar <= PressureFullScaleBar; bar++) + { + double y = PressureY(bar); + context.DrawLine(bar == 5 ? GridBoldPen : GridPen, new Point(left, y), new Point(right, y)); + if (bar % 2 == 0) + Print(context, bar.ToString(CultureInfo.InvariantCulture), 52, y - 7, 10, TextBrush); + } + + for (int i = 1; i < Channels; i++) + context.DrawLine(GridPen, new Point(left, ChannelsTop + i * ChannelRow), + new Point(right, ChannelsTop + i * ChannelRow)); + } + + /// The clock along the top edge: a line every minute of uniform time, or ticks where minutes pass on a distance axis. + private void DrawTimeAxis(DrawingContext context, HaslerTape tape) + { + double pxPerMinute = ByDistance + ? Scale / Math.Max(tape.Duration / 60.0 / Math.Max(Length, 0.001), 0.001) + : Scale; + double step = TapeFigures.MinuteStep(pxPerMinute); + double next = Math.Ceiling(ClockAt(tape.Samples[0]) / 60 / step) * step * 60; + double lastLabel = double.NegativeInfinity; + foreach (var s in tape.Samples) + { + double clock = ClockAt(s); + if (clock < next) + continue; + double x = XOf(s); + context.DrawLine(GridPen, new Point(x, SpeedTop), new Point(x, SpeedBottom)); + context.DrawLine(FramePen, new Point(x, SpeedTop - 4), new Point(x, SpeedTop)); + if (x - lastLabel > 46) + { + var t = TimeSpan.FromSeconds(next % 86400); + Print(context, $"{(int)t.TotalHours:00}:{t.Minutes:00}", x - 14, 3, 10, TextBrush); + lastLabel = x; + } + next = (Math.Floor(clock / 60 / step) + 1) * step * 60; + } + Print(context, "h:min", 4, 3, 10, DimBrush); + } + + /// Kilometres along the bottom edge. + private void DrawDistanceAxis(DrawingContext context, HaslerTape tape) + { + double stepKm = TapeFigures.KmStep(ByDistance ? Scale : Scale * tape.Duration / 60 / Math.Max(tape.DistanceM / 1000, 0.001)); + double next = 0; + double y = ChannelsBottom; + double lastLabel = double.NegativeInfinity; + foreach (var s in tape.Samples) + { + if (s.DistanceM < next * 1000) + continue; + double x = XOf(s); + context.DrawLine(FramePen, new Point(x, y), new Point(x, y + 4)); + if (ByDistance) + context.DrawLine(GridPen, new Point(x, PressureTop), new Point(x, PressureBottom)); + if (x - lastLabel > 40) + { + Print(context, next.ToString("0.#", CultureInfo.InvariantCulture), x - 6, y + 5, 10, TextBrush); + lastLabel = x; + } + next = (Math.Floor(s.DistanceM / 1000 / stepKm) + 1) * stepKm; + } + Print(context, "km", 4, y + 5, 10, DimBrush); + } + + private void DrawChannel(DrawingContext context, HaslerTape tape, int row, Func on, IBrush brush) + { + double top = ChannelsTop + row * ChannelRow + 4, height = ChannelRow - 8; + double? start = null, last = LeftMargin; + foreach (var s in tape.Samples) + { + double x = XOf(s); + if (on(s)) + start ??= x; + else if (start is { } from) + { + context.DrawRectangle(brush, null, new Rect(from, top, Math.Max(1, x - from), height)); + start = null; + } + last = x; + } + if (start is { } open) + context.DrawRectangle(brush, null, new Rect(open, top, Math.Max(1, last.Value - open), height)); + } + + private void DrawLegend(DrawingContext context) + { + Print(context, "km/h", 4, SpeedTop + 2, 10, DimBrush); + Print(context, "bar", 4, PressureTop + 2, 10, DimBrush); + Print(context, App.Loc["HaslerTrackPipe"], 4, PressureTop + 22, 10, PipePen.Brush!); + Print(context, App.Loc["HaslerTrackCylinders"], 4, PressureTop + 38, 10, CylinderPen.Brush!); + Print(context, App.Loc["HaslerTrackShp"], 4, ChannelsTop + 2, 10, TextBrush); + Print(context, App.Loc["HaslerTrackCurrent"], 4, ChannelsTop + ChannelRow + 2, 10, TextBrush); + Print(context, App.Loc["HaslerTrackBrake"], 4, ChannelsTop + 2 * ChannelRow + 2, 10, TextBrush); + } + + private static void Print(DrawingContext context, string text, double x, double y, double size, IBrush brush) => + context.DrawText(new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, Face, size, + brush), new Point(x, y)); +} diff --git a/StarterNG/Views/Tapes/HaslerTapeView.cs b/StarterNG/Views/Tapes/HaslerTapeView.cs new file mode 100644 index 0000000..3bf778e --- /dev/null +++ b/StarterNG/Views/Tapes/HaslerTapeView.cs @@ -0,0 +1,359 @@ +using System; +using System.Globalization; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using StarterNG.Domain.Tapes; + +namespace StarterNG.Views.Tapes; + +/// +/// A trip drawn as a Hasler tape: blue-grey waxed paper, on which every record is +/// a light, slightly fuzzy line. +/// +/// Top margin: a hole every 0.5 km, with the full hours punched in dots between them. +/// One field ruled every 10 km/h. The speed curve runs in the lower part and its +/// figures sit on their lines across the paper. The minute stylus zig-zags in the +/// upper part on a 10 minute cycle, peaking on every tenth minute. While the train +/// stands it keeps going, so a stop on a distance-fed tape shows as a brush of strokes. +/// Two red lines. Every vigilance (SHP) acknowledgement is a stroke up through the upper one. +/// Motor current: a step of its own stylus. +/// Brake cylinder pressure: spikes rising from a baseline near the bottom edge. +/// +/// The paper is fed by distance or by time. +/// +public sealed class HaslerTapeView : TapeView +{ + public HaslerTapeView() => RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.LowQuality); + + private const double Pitch = 16; // px between printed lines, one per 10 km/h + private const int MinuteLines = 6; // the minute stylus swings over this many lines + + private const double TopEdge = 26; + private const double FieldTop = TopEdge + 10; + private const double ShpGap = 14; + private const double ShpReach = 9; + private const double RedGap = 22; + private const double CurrentBand = 16; + private const double BrakeBand = 46; + private const double BottomEdge = 20; + + private const double MinuteCycle = 600; // s, from one peak to the next + private const double PerforationStepM = 500; + private const double BrakeFullScaleBar = 5; + private const double LabelSpacing = 170; + + private const int GrainSize = 512; + private const int GrainPatch = 256; // px laid at a time, each from its own place in the grain + private const int MottlingCell = 8; // px of paper to a pixel of the mottling + + private static readonly IBrush PaperBrush = new SolidColorBrush(Color.Parse("#7A94A5")); + private static readonly Bitmap Grain = GrainTexture(); + private static Bitmap? _mottling; + private static readonly IBrush EdgeBrush = new SolidColorBrush(Color.Parse("#A8B9C3")); + private static readonly IBrush PrintBrush = new SolidColorBrush(Color.Parse("#2A2B33")); + private static readonly IBrush HoleBrush = new SolidColorBrush(Color.Parse("#EEF3F5")); + private static readonly IPen PrintPen = new Pen(PrintBrush, 1); + private static readonly IPen EdgePen = new Pen(PrintBrush, 1.2); + private static readonly IPen RedPen = new Pen(new SolidColorBrush(Color.Parse("#9C4E63")), 4); + private static readonly IPen StylusPen = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.9), 1.5, + lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round); + private static readonly IPen StylusHalo = new Pen(new SolidColorBrush(Color.Parse("#E4EDF2"), 0.22), 3.6, + lineJoin: PenLineJoin.Round, lineCap: PenLineCap.Round); + private static readonly Typeface PrintFace = new(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold); + + // 5x7 dot figures for the hours punched into the top edge. + private static readonly string[] Digits = + { + "01110100011001110101110011000101110", "00100011000010000100001000010001110", + "01110100010000100010001000100011111", "11111000100010000010000011000101110", + "00010001100101010010111110001000010", "11111100001111000001000011000101110", + "00110010001000011110100011000101110", "11111000010001000100010000100001000", + "01110100011000101110100011000101110", "01110100011000101111000010001001100" + }; + + protected override double LeftMargin => 8; + + private int SpeedLines => (int)(TopSpeed / 10); + + private double FieldBottom => FieldTop + (SpeedLines + MinuteLines) * Pitch; + + private double RedTop => FieldBottom + ShpGap; + + private double CurrentTop => RedTop + RedGap + 4; + + private double BrakeBase => CurrentTop + CurrentBand + BrakeBand; + + protected override double TapeHeight => BrakeBase + 6 + BottomEdge; + + private double SpeedY(double speed) => FieldBottom - Math.Min(speed, TopSpeed) / 10 * Pitch; + + /// The minute stylus: at the top on every tenth minute, at the bottom five minutes later. + private static double MinuteY(double clock) + { + double phase = clock % MinuteCycle / MinuteCycle; + return FieldTop + MinuteLines * Pitch * (1 - Math.Abs(1 - 2 * phase)); + } + + private double BrakeY(double press) => BrakeBase - BrakeBand * Math.Clamp(press / BrakeFullScaleBar, 0, 1); + + protected override void DrawBlank(DrawingContext context) + { + double width = Bounds.Width; + double height = TapeHeight; + DrawPaper(context, width, height); + context.DrawRectangle(EdgeBrush, null, new Rect(0, 0, width, TopEdge)); + context.DrawRectangle(EdgeBrush, null, new Rect(0, height - BottomEdge, width, BottomEdge)); + context.DrawLine(EdgePen, new Point(0, height - BottomEdge), new Point(width, height - BottomEdge)); + } + + protected override void DrawTape(DrawingContext context, HaslerTape tape) + { + DrawPrint(context, Bounds.Width); + DrawEdges(context, tape, TapeHeight); + DrawStylus(context, tape, s => MinuteY(ClockAt(s))); + DrawStylus(context, tape, s => SpeedY(s.SpeedKmh)); + DrawShp(context, tape); + DrawStylus(context, tape, s => s.Current ? CurrentTop + CurrentBand - 4 : CurrentTop + 3); + DrawStylus(context, tape, s => BrakeY(s.BrakePressBar)); + } + + /// + /// What the paper comes printed with: a line every 10 km/h across the whole + /// field, the speed figures climbing across it - each on its own line, which + /// breaks around it - and the two red lines. + /// + private void DrawPrint(DrawingContext context, double width) + { + int lines = SpeedLines + MinuteLines; + var gaps = new System.Collections.Generic.List<(int Line, double From, double To, FormattedText Text)>(); + double block = SpeedLines * LabelSpacing; + for (double start = LeftMargin + LabelSpacing / 2; start < width; start += block) + for (int v = 1; v <= SpeedLines; v++) + { + double x = start + (v - 1) * LabelSpacing; + if (x >= width) + break; + var text = new FormattedText((v * 10).ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture, FlowDirection.LeftToRight, PrintFace, 15, + PrintBrush); + gaps.Add((v, x - 5, x + text.Width + 5, text)); + } + + for (int line = 0; line <= lines; line++) + { + double y = FieldBottom - line * Pitch; + double from = 0; + foreach (var gap in gaps) + if (gap.Line == line) + { + context.DrawLine(PrintPen, new Point(from, y), new Point(gap.From, y)); + from = gap.To; + } + context.DrawLine(PrintPen, new Point(from, y), new Point(width, y)); + } + + foreach (var gap in gaps) + context.DrawText(gap.Text, new Point(gap.From + 5, SpeedY(gap.Line * 10) - gap.Text.Height / 2)); + + context.DrawLine(RedPen, new Point(0, RedTop), new Point(width, RedTop)); + context.DrawLine(RedPen, new Point(0, RedTop + RedGap), new Point(width, RedTop + RedGap)); + } + + /// + /// The transport holes along both edges, one per 0.5 km, and the hour punched + /// as a figure of dots into the top edge where the clock passes a full hour - + /// midnight as 24. + /// + private void DrawEdges(DrawingContext context, HaslerTape tape, double height) + { + var figures = new System.Collections.Generic.List<(double From, double To)>(); + double nextHour = Math.Ceiling(ClockAt(tape.Samples[0]) / 3600) * 3600; + foreach (var s in tape.Samples) + { + double clock = ClockAt(s); + if (clock >= nextHour) + { + int hour = (int)(clock / 3600) % 24; + figures.Add(PunchHour(context, hour == 0 ? 24 : hour, XOf(s))); + nextHour = (Math.Floor(clock / 3600) + 1) * 3600; + } + } + + double nextHole = 0; + foreach (var s in tape.Samples) + if (s.DistanceM >= nextHole) + { + double x = XOf(s); + if (!figures.Exists(f => x > f.From - 3 && x < f.To + 3)) + Hole(context, x, TopEdge / 2); + Hole(context, x, height - BottomEdge / 2); + nextHole = (Math.Floor(s.DistanceM / PerforationStepM) + 1) * PerforationStepM; + } + } + + private static (double From, double To) PunchHour(DrawingContext context, int hour, double x) + { + const double dot = 3; + string figures = hour.ToString(CultureInfo.InvariantCulture); + double width = figures.Length * 6 * dot; + double left = x - width / 2; + double top = (TopEdge - 7 * dot) / 2 + dot / 2; + for (int f = 0; f < figures.Length; f++) + { + string pattern = Digits[figures[f] - '0']; + for (int i = 0; i < pattern.Length; i++) + if (pattern[i] == '1') + context.DrawEllipse(HoleBrush, null, + new Point(left + f * 6 * dot + i % 5 * dot, top + i / 5 * dot), 1.2, 1.2); + } + return (left, left + width); + } + + private static void Hole(DrawingContext context, double x, double y) + { + context.DrawEllipse(HoleBrush, null, new Point(x, y), 2.4, 2.4); + context.DrawEllipse(HoleBrush, null, new Point(x - 2.8, y), 0.9, 0.9); + context.DrawEllipse(HoleBrush, null, new Point(x + 2.8, y), 0.9, 0.9); + } + + private void DrawStylus(DrawingContext context, HaslerTape tape, Func y) + { + var geometry = Trace(tape, y); + context.DrawGeometry(null, StylusHalo, geometry); + context.DrawGeometry(null, StylusPen, geometry); + } + + /// The vigilance stylus rests under the upper red line and strokes up through it on every acknowledgement. + private void DrawShp(DrawingContext context, HaslerTape tape) + { + double rest = RedTop + 6; + double left = LeftMargin, right = TapeRight; + context.DrawLine(StylusHalo, new Point(left, rest), new Point(right, rest)); + context.DrawLine(StylusPen, new Point(left, rest), new Point(right, rest)); + bool alarm = false; + foreach (var s in tape.Samples) + { + if (alarm && !s.SecurityAlarm) + { + double x = XOf(s); + context.DrawLine(StylusHalo, new Point(x, rest), new Point(x, RedTop - ShpReach)); + context.DrawLine(StylusPen, new Point(x, rest), new Point(x, RedTop - ShpReach)); + } + alarm = s.SecurityAlarm; + } + } + + /// + /// The paper, which repeats nowhere: the mottling is one bitmap stretched over the + /// whole tape, and the grain, having no shapes to join up, is laid in patches from + /// scattered places of its texture. A tiled ImageBrush loses tiles on screen, leaving + /// holes in the paper; plain DrawImage calls do not, and the solid colour underneath + /// covers anything the bitmaps do not. + /// + private static void DrawPaper(DrawingContext context, double width, double height) + { + context.DrawRectangle(PaperBrush, null, new Rect(0, 0, width, height)); + context.DrawImage(MottlingFor(width, height), new Rect(0, 0, width / MottlingCell, height / MottlingCell), + new Rect(0, 0, width, height)); + + for (int row = 0; row * GrainPatch < height; row++) + for (int column = 0; column * GrainPatch < width; column++) + { + uint place = Hash(column, row); + // The last row and column are cut to the tape, not left hanging over its edge. + var size = new Size(Math.Min(GrainPatch, width - column * GrainPatch), + Math.Min(GrainPatch, height - row * GrainPatch)); + context.DrawImage(Grain, new Rect(new Point(place % GrainPatch, (place >> 16) % GrainPatch), size), + new Rect(new Point(column * GrainPatch, row * GrainPatch), size)); + } + } + + /// + /// The light and dark clouds of the coating. Worked out from the place on the paper + /// alone, so one bitmap serves every tape it is big enough for and only ever grows. + /// + private static Bitmap MottlingFor(double width, double height) + { + int columns = (int)Math.Ceiling(width / MottlingCell) + 1; + int rows = (int)Math.Ceiling(height / MottlingCell) + 1; + if (_mottling is not null && _mottling.PixelSize.Width >= columns && _mottling.PixelSize.Height >= rows) + return _mottling; + + columns = Math.Max(columns + columns / 4, _mottling?.PixelSize.Width ?? 0); + rows = Math.Max(rows, _mottling?.PixelSize.Height ?? 0); + var pixels = new byte[columns * rows * 4]; + for (int y = 0; y < rows; y++) + for (int x = 0; x < columns; x++) + { + double shade = Noise(x, y, 5) * 18 + Noise(x, y, 23) * 12; + int o = (y * columns + x) * 4; + pixels[o] = Channel(165 + shade); // B + pixels[o + 1] = Channel(148 + shade); // G + pixels[o + 2] = Channel(122 + shade); // R + pixels[o + 3] = 255; + } + + return _mottling = ToBitmap(pixels, columns, rows, AlphaFormat.Opaque); + } + + /// The fine grain of the coating and flecks of it already gone, laid over the mottling. + private static Bitmap GrainTexture() + { + var random = new Random(2020); + var pixels = new byte[GrainSize * GrainSize * 4]; + for (int o = 0; o < pixels.Length; o += 4) + { + double shade = (random.NextDouble() - 0.5) * 20; + if (random.NextDouble() < 0.004) + shade += 64; // a fleck of coating already gone + + // Premultiplied: white to lighten, black to darken. + byte alpha = Channel(Math.Abs(shade)); + pixels[o] = pixels[o + 1] = pixels[o + 2] = shade > 0 ? alpha : (byte)0; + pixels[o + 3] = alpha; + } + + return ToBitmap(pixels, GrainSize, GrainSize, AlphaFormat.Premul); + } + + /// Smooth noise over cells of the given size, -0.5 to 0.5; a place always gives the same value. + private static double Noise(int x, int y, int cell) + { + int left = x / cell, top = y / cell; + double across = Fade((double)(x % cell) / cell), down = Fade((double)(y % cell) / cell); + return Lerp(Lerp(Corner(left, top), Corner(left + 1, top), across), + Lerp(Corner(left, top + 1), Corner(left + 1, top + 1), across), down); + + double Corner(int i, int j) => Hash(i, j + cell * 7919) / (double)uint.MaxValue - 0.5; + static double Fade(double t) => t * t * (3 - 2 * t); + static double Lerp(double a, double b, double t) => a + (b - a) * t; + } + + private static uint Hash(int x, int y) + { + unchecked + { + uint h = (uint)x * 73856093u ^ (uint)y * 19349663u; + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + return h ^ h >> 16; + } + } + + private static byte Channel(double v) => (byte)Math.Clamp(v, 0, 255); + + private static Bitmap ToBitmap(byte[] pixels, int width, int height, AlphaFormat alpha) + { + var bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), PixelFormat.Bgra8888, alpha); + using (var buffer = bitmap.Lock()) + for (int y = 0; y < height; y++) + Marshal.Copy(pixels, y * width * 4, buffer.Address + y * buffer.RowBytes, width * 4); + + return bitmap; + } +} diff --git a/StarterNG/Views/Tapes/HaslerTapeWindow.cs b/StarterNG/Views/Tapes/HaslerTapeWindow.cs new file mode 100644 index 0000000..03fa3ba --- /dev/null +++ b/StarterNG/Views/Tapes/HaslerTapeWindow.cs @@ -0,0 +1,566 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using StarterNG.Application; +using StarterNG.Domain.Settings; +using StarterNG.Domain.Tapes; + +namespace StarterNG.Views.Tapes; + +/// +/// Reads the speed recorder tapes the simulator writes to physicslog/ - one per +/// driven vehicle - grouped by the run they came from, and lays them out as Hasler +/// paper, fed by distance, or as the screen of an electronic recorder for vehicles +/// that have one, by time or by distance. The clock and the recorder come from +/// what the starter noted at the launch; both can be set by hand. While a session +/// runs "Live" goes to the tape of the vehicle the player is in and keeps +/// re-reading it. +/// +public sealed class HaslerTapeWindow : Window +{ + private const double DefaultKmScale = 40; + private const double DefaultMinuteScale = 30; + private const double MaxExportWidth = 32000; + + private static readonly IBrush RunningBrush = new SolidColorBrush(Color.Parse("#41c400")); + + private readonly HaslerTapes _tapes = AppServices.Current.Tapes; + private readonly TreeView _list = new() { Width = 260 }; + private readonly ComboBox _feed = new() { Width = 120, VerticalAlignment = VerticalAlignment.Center }; + private readonly Slider _scale = new() { Width = 140, Minimum = 4, Maximum = 600, VerticalAlignment = VerticalAlignment.Center }; + private readonly ComboBox _recorder = new() { Width = 150, VerticalAlignment = VerticalAlignment.Center }; + private readonly CheckBox _live = new() { VerticalAlignment = VerticalAlignment.Center }; + private readonly TextBox _startClock = new() { Width = 64, PlaceholderText = "hh:mm", VerticalAlignment = VerticalAlignment.Center }; + private readonly TextBlock _summary = new() { Margin = new Thickness(8, 4), TextWrapping = TextWrapping.Wrap }; + private readonly TextBlock _cursor = new() { Margin = new Thickness(8, 4), FontSize = 12, Opacity = 0.85 }; + private readonly TextBlock _empty = new() + { + Margin = new Thickness(24), + TextWrapping = TextWrapping.Wrap, + Opacity = 0.75, + IsVisible = false + }; + private readonly ScrollViewer _scroll; + private TapeView _view; + private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) }; + private TapeContext? _context; + private readonly Control _recordingOff; + private readonly Control _logOff; + private readonly Dictionary _expanded = new(); + + private TapeFile? _shown; + private string? _followed; + private string _listed = ""; + private bool _running; + private bool _loading; + private bool _looking; + + /// A setting went on from here; the settings page's boxes have to follow it. + public event Action? LogEnabled; + + public HaslerTapeWindow() + { + Title = App.Loc["HaslerTapes"]; + Width = 1100; + Height = 560; + MinWidth = 700; + MinHeight = 420; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + _feed.ItemsSource = new[] { App.Loc["HaslerByDistance"], App.Loc["HaslerByTime"] }; + _feed.SelectedIndex = 0; + _feed.IsEnabled = false; + _feed.SelectionChanged += (_, _) => FeedChanged(); + _scale.Value = DefaultKmScale; + _scale.PropertyChanged += (_, e) => + { + if (e.Property == RangeBase.ValueProperty) + _view.Scale = _scale.Value; + }; + _live.Content = App.Loc["HaslerLive"]; + ToolTip.SetShowOnDisabled(_live, true); + _live.IsCheckedChanged += async (_, _) => + { + if (_live.IsChecked == true) + await RefreshListAsync(toDriven: true); + }; + _timer.Tick += async (_, _) => await LookAtSessionsAsync(); + _startClock.TextChanged += (_, _) => _view.StartClock = TapeFigures.ParseClock(_startClock.Text); + _recorder.ItemsSource = new[] { App.Loc["HaslerRecorderHasler"], App.Loc["HaslerRecorderElectronic"] }; + _recorder.SelectedIndex = 0; + _recorder.SelectionChanged += (_, _) => RecorderChanged(); + ToolTip.SetTip(_startClock, App.Loc["HaslerStartClockTip"]); + + ScrollViewer.SetHorizontalScrollBarVisibility(_list, ScrollBarVisibility.Disabled); + _list.SelectionChanged += async (_, _) => + { + // A session stands for its first tape, the player's own if it has one. + if (_list.SelectedItem is TreeViewItem { Tag: TapeSession } session) + session.IsExpanded = true; + var file = _list.SelectedItem switch + { + TreeViewItem { Tag: TapeFile tape } => tape, + TreeViewItem { Tag: TapeSession { Tapes.Count: > 0 } run } => run.Tapes[0], + _ => null + }; + if (file is null || file == _shown) + return; + + ApplyContext(_tapes.ContextOf(file)); + await ShowAsync(file, scrollToEnd: false); + }; + + _view = NewView(RecorderKind.Hasler); + _view.Scale = DefaultKmScale; + _view.Hovered += ShowCursor; + _scroll = new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = _view + }; + + _recordingOff = Strip("HaslerRecordingOff", "HaslerRecordingEnable", settings => settings.PhysicsLog = true); + _logOff = Strip("HaslerLogOff", "HaslerLogEnable", settings => settings.DebugLog = true); + + var folder = ToolButton(App.Loc["HaslerOpenFolder"]); + folder.Click += (_, _) => + { + AppServices.Current.Files.CreateDirectory(_tapes.Folder); + AppServices.Current.Processes.OpenInShell(_tapes.Folder); + }; + + var export = ToolButton(App.Loc["HaslerExportPng"]); + export.Click += async (_, _) => await ExportPngAsync(); + + var toolbar = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4, 2) }; + foreach (var control in new Control[] + { + Label(App.Loc["HaslerRecorder"]), _recorder, Label(App.Loc["HaslerFeed"]), _feed, + Label(App.Loc["HaslerScale"]), _scale, + Label(App.Loc["HaslerStartClock"]), _startClock, _live, folder, export + }) + { + control.Margin = new Thickness(4); + toolbar.Children.Add(control); + } + + var tapeArea = new DockPanel(); + DockPanel.SetDock(_recordingOff, Dock.Top); + DockPanel.SetDock(_logOff, Dock.Top); + DockPanel.SetDock(toolbar, Dock.Top); + DockPanel.SetDock(_summary, Dock.Top); + DockPanel.SetDock(_cursor, Dock.Bottom); + tapeArea.Children.Add(_recordingOff); + tapeArea.Children.Add(_logOff); + tapeArea.Children.Add(toolbar); + tapeArea.Children.Add(_summary); + tapeArea.Children.Add(_cursor); + tapeArea.Children.Add(new Panel { Children = { _scroll, _empty } }); + + var root = new DockPanel(); + var listBorder = new Border + { + BorderBrush = new SolidColorBrush(Color.Parse("#3A424A")), + BorderThickness = new Thickness(0, 0, 1, 0), + Child = _list + }; + DockPanel.SetDock(listBorder, Dock.Left); + root.Children.Add(listBorder); + root.Children.Add(tapeArea); + Content = root; + + Opened += async (_, _) => + { + _timer.Start(); + await RefreshListAsync(); + }; + Activated += (_, _) => ShowWhatIsKnown(); + Closed += (_, _) => _timer.Stop(); + } + + /// + /// What gets recorded, and whether there is a trip for "live" to follow: a session + /// that runs, and the player in a vehicle. + /// + private void ShowWhatIsKnown() + { + var settings = AppServices.Current.Settings; + _recordingOff.IsVisible = !settings.PhysicsLog; + _logOff.IsVisible = settings.PhysicsLog && !settings.DebugLog; + + bool canFollow = _running && _tapes.DrivenVehicle is not null; + _live.IsEnabled = canFollow; + if (!canFollow) + _live.IsChecked = false; + ToolTip.SetTip(_live, App.Loc[canFollow ? "HaslerLiveTip" : "HaslerLiveIdle"]); + } + + private DockPanel Strip(string textKey, string buttonKey, Action enable) + { + var button = ToolButton(App.Loc[buttonKey]); + button.Classes.Add("Accent"); + button.Margin = new Thickness(8, 0, 0, 0); + button.Click += (_, _) => Enable(enable); + DockPanel.SetDock(button, Dock.Right); + return new DockPanel + { + Margin = new Thickness(8, 4), + IsVisible = false, + Children = + { + button, + new TextBlock + { + Text = App.Loc[textKey], + TextWrapping = TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + } + + /// Only this one setting: other unsaved edits on the settings page are not this button's to save. + private void Enable(Action enable) + { + var store = AppServices.Current.SettingsStore; + enable(store.Settings); + store.Save(); + LogEnabled?.Invoke(); + ShowWhatIsKnown(); + if (store.LastSaveFailed) + _summary.Text = App.Loc["SettingsNotSaved"]; + } + + private static Button ToolButton(string text) => new() + { + Content = text, + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) + }; + + private static TextBlock Label(string text) => new() { Text = text, VerticalAlignment = VerticalAlignment.Center }; + + private RecorderKind Recorder => _recorder.SelectedIndex == 1 ? RecorderKind.Electronic : RecorderKind.Hasler; + + private static TapeView NewView(RecorderKind kind) => kind == RecorderKind.Electronic + ? new ElectronicTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top } + : new HaslerTapeView { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top }; + + /// + /// The same trip drawn the other way. Hasler paper is fed by distance and + /// nothing else; the screen of an electronic recorder runs on the clock, and + /// may be switched to distance. + /// + private void RecorderChanged() + { + bool electronic = Recorder == RecorderKind.Electronic; + _feed.IsEnabled = electronic; + _feed.SelectedIndex = electronic ? 1 : 0; + if ((_view is ElectronicTapeView) == electronic) + return; + + var old = _view; + old.Hovered -= ShowCursor; + _view = NewView(Recorder); + _view.CopySettingsFrom(old); + _view.Hovered += ShowCursor; + _scroll.Content = _view; + // The other view is not as tall; an offset kept from it would hide its top rows. + _scroll.Offset = new Vector(_scroll.Offset.X, 0); + _cursor.Text = ""; + } + + private async Task RefreshListAsync(bool toDriven = false) => + ShowSessions(await Task.Run(_tapes.Sessions), toDriven); + + /// The tape of the vehicle the player is in, in the session that runs. + private static TapeFile? DrivenTape(IEnumerable sessions) => + sessions.FirstOrDefault(session => session.Running)?.Tapes + .FirstOrDefault(tape => tape.Role is TapeRole.Driven or TapeRole.StartedIn); + + private static string Fingerprint(IEnumerable sessions) => + string.Join('\n', sessions.Select(session => + $"{session.StartedUtc:O} {session.LastWrittenUtc:yyyyMMddHHmm} {session.Running} " + + string.Join(' ', session.Tapes.Select(tape => $"{tape.Vehicle}:{tape.Role}")))); + + /// Sessions newest first, the newest one open until the reader has said otherwise. + private void ShowSessions(IReadOnlyList sessions, bool toDriven) + { + _running = sessions.Any(session => session.Running); + _listed = Fingerprint(sessions); + ShowWhatIsKnown(); + + string? driven = DrivenTape(sessions)?.Path; + if (toDriven) + _followed = driven; + string? selected = toDriven && driven is not null ? driven : _shown?.Path; + + foreach (var node in _list.Items.OfType()) + _expanded[((TapeSession)node.Tag!).StartedUtc] = node.IsExpanded; + _list.Items.Clear(); + + (TreeViewItem Node, TreeViewItem Tape)? first = null, pick = null; + for (int i = 0; i < sessions.Count; i++) + { + var node = SessionItem(sessions[i], _expanded.GetValueOrDefault(sessions[i].StartedUtc, i == 0)); + _list.Items.Add(node); + foreach (var tape in node.Items.OfType()) + { + first ??= (node, tape); + if (((TapeFile)tape.Tag!).Path == selected) + pick = (node, tape); + } + } + + _empty.Text = string.Format(App.Loc["HaslerNoTapes"], _tapes.Folder); + _empty.IsVisible = first is null; + _scroll.IsVisible = first is not null; + if ((pick ?? first) is not { } chosen) + { + _shown = null; + _view.Tape = null; + _summary.Text = ""; + return; + } + + chosen.Node.IsExpanded = true; + _list.SelectedItem = chosen.Tape; + } + + private static TreeViewItem SessionItem(TapeSession session, bool open) + { + string title = session.Launch is { } launch + ? string.Join(" · ", new[] { string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName, launch.Train } + .Where(part => !string.IsNullOrWhiteSpace(part))) + : App.Loc["HaslerSession"]; + + var started = session.StartedUtc.ToLocalTime(); + var written = session.LastWrittenUtc.ToLocalTime(); + string when = $"{started:dd.MM HH:mm}"; + if (!session.Running && written - started >= TimeSpan.FromMinutes(1)) + when += $"–{written:HH:mm}"; + if (session.Tapes.Count > 0) + when += " · " + string.Format(App.Loc["HaslerSessionTapes"], session.Tapes.Count); + + var details = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + details.Children.Add(new TextBlock { Text = when, FontSize = 11, Opacity = 0.7 }); + if (session.Running) + details.Children.Add(new TextBlock + { + Text = App.Loc["HaslerSessionRunning"], + FontSize = 11, + FontWeight = FontWeight.SemiBold, + Foreground = RunningBrush + }); + + var name = new TextBlock { Text = title, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis }; + ToolTip.SetTip(name, title); + + var item = new TreeViewItem + { + Header = new StackPanel { Spacing = 1, Children = { name, details } }, + Tag = session, + IsExpanded = open + }; + foreach (var tape in session.Tapes) + item.Items.Add(TapeItem(tape)); + return item; + } + + private static TreeViewItem TapeItem(TapeFile file) + { + var text = new StackPanel { Spacing = 1 }; + text.Children.Add(new TextBlock + { + Text = file.Vehicle, + FontWeight = file.Role == TapeRole.Other ? FontWeight.Normal : FontWeight.SemiBold, + TextTrimming = TextTrimming.CharacterEllipsis + }); + string? role = file.Role switch + { + TapeRole.Driven => App.Loc["HaslerRoleDriven"], + TapeRole.StartedIn => App.Loc["HaslerRoleStartedIn"], + TapeRole.DrivenEarlier => App.Loc["HaslerRoleDrivenEarlier"], + TapeRole.OwnConsist => App.Loc["HaslerRoleConsist"], + _ => null + }; + if (role is not null) + text.Children.Add(new TextBlock { Text = role, FontSize = 11, Opacity = 0.7 }); + return new TreeViewItem { Header = text, Tag = file }; + } + + /// + /// What the starter noted at the launch the tape came from: the clock the + /// scenery started at and the recorder its vehicle has. A tape from before + /// the starter noted anything keeps the hour unknown and the recorder guessed. + /// + private void ApplyContext(TapeContext context) + { + _context = context; + _startClock.Text = context.StartClock is { } clock + ? clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture) + : ""; + _recorder.SelectedIndex = context.Recorder == RecorderKind.Electronic ? 1 : 0; + } + + private async Task ShowAsync(TapeFile file, bool scrollToEnd) + { + if (_loading) + return; + _loading = true; + try + { + var tape = await Task.Run(() => _tapes.Load(file)); + _shown = file; + if (tape is null) + { + _view.Tape = null; + _summary.Text = string.Format(App.Loc["HaslerUnreadable"], file.Path); + return; + } + + _view.Tape = tape; + _summary.Text = string.Format(App.Loc["HaslerSummary"], file.Vehicle, TapeFigures.Clock(tape.Duration), + TapeFigures.Clock(tape.RunningTime), TapeFigures.Clock(tape.StandingTime), + tape.DistanceM / 1000.0, tape.MaxSpeedKmh, tape.AverageSpeedKmh, + tape.AverageWithStopsKmh); + if (_context?.Scenery is { } scenery) + _summary.Text += " · " + string.Format(App.Loc["HaslerScenario"], scenery); + if (_context?.Train is { } train) + _summary.Text += " · " + string.Format(App.Loc["HaslerTrain"], train); + // The log counts seconds from the start: an hour comes only from what the starter noted. + if (string.IsNullOrWhiteSpace(_startClock.Text)) + _summary.Text += " · " + App.Loc["HaslerClockUnknown"]; + if (scrollToEnd) + Dispatcher.UIThread.Post(() => _scroll.Offset = new Vector(double.MaxValue, _scroll.Offset.Y), + DispatcherPriority.Background); + } + finally + { + _loading = false; + } + } + + /// + /// Keeps the list up with the sessions, and for a trip followed live re-reads the + /// tape when the simulator has added to it. A change of vehicle or a new launch's + /// own tape takes over; one picked by hand stays until then. + /// + private async Task LookAtSessionsAsync() + { + if (_loading || _looking) + return; + + _looking = true; + try + { + var sessions = await Task.Run(_tapes.Sessions); + bool live = _live.IsChecked == true; + bool newDriven = live && DrivenTape(sessions) is { } driven && driven.Path != _followed; + if (newDriven || Fingerprint(sessions) != _listed) + { + ShowSessions(sessions, toDriven: newDriven); + return; + } + + if (!live || _shown is not { } shown) + return; + var now = sessions.SelectMany(session => session.Tapes).FirstOrDefault(tape => tape.Path == shown.Path); + if (now is null || (now.Size == shown.Size && now.LastWriteUtc == shown.LastWriteUtc)) + return; + + // Following the end only when the reader is already there, so scrolling back is not undone. + bool atEnd = _scroll.Offset.X + _scroll.Viewport.Width >= _scroll.Extent.Width - 4; + await ShowAsync(now, scrollToEnd: atEnd); + } + finally + { + _looking = false; + } + } + + private void FeedChanged() + { + bool byDistance = _feed.SelectedIndex != 1; + if (_view.ByDistance == byDistance) + return; + _view.ByDistance = byDistance; + _scale.Value = byDistance ? DefaultKmScale : DefaultMinuteScale; + _scale.Maximum = byDistance ? 600 : 300; + } + + /// The whole tape, not just what is scrolled into view, drawn afresh at the chosen scale. + private async Task ExportPngAsync() + { + if (_view.Tape is not { IsEmpty: false } tape || _shown is null) + return; + + var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = App.Loc["HaslerExportPng"], + SuggestedFileName = _shown.Vehicle + ".png", + DefaultExtension = "png", + FileTypeChoices = new[] { new FilePickerFileType("PNG") { Patterns = new[] { "*.png" } } } + }); + if (file is null) + return; + + var copy = NewView(Recorder); + copy.CopySettingsFrom(_view); + // The trip it was asked for: a reload may have come in behind the file picker. + copy.Tape = tape; + copy.Measure(Size.Infinity); + if (copy.DesiredSize.Width > MaxExportWidth) + { + // A bitmap that wide is past what the renderer takes; the paper is squeezed to fit instead. + copy.Scale *= MaxExportWidth / copy.DesiredSize.Width; + copy.Measure(Size.Infinity); + } + copy.Arrange(new Rect(copy.DesiredSize)); + + try + { + using var bitmap = new RenderTargetBitmap(new PixelSize((int)Math.Ceiling(copy.DesiredSize.Width), + (int)Math.Ceiling(copy.DesiredSize.Height))); + bitmap.Render(copy); + await using var stream = await file.OpenWriteAsync(); + bitmap.Save(stream); + } + catch (Exception ex) + { + AppServices.Current.Log.Log($"exporting {_shown.Path} to {file.Name}", ex); + _summary.Text = string.Format(App.Loc["HaslerExportFailed"], file.Name); + } + } + + private void ShowCursor(TapeSample? sample) + { + if (sample is not { } s || _view.Tape is null) + { + _cursor.Text = ""; + return; + } + // Only what the record in view shows: a Hasler tape has no brake pipe and + // marks current as on or off, and the electronic screen draws the current + // and the vigilance alarm as channels, not values. + var clock = TimeSpan.FromSeconds(_view.ClockAt(s) % 86400); + string time = clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); + _cursor.Text = Recorder == RecorderKind.Electronic + ? string.Format(App.Loc["ElectronicCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.PipePressBar, + s.BrakePressBar, YesNo(s.Current), YesNo(s.SecurityAlarm)) + : string.Format(App.Loc["HaslerCursor"], time, s.DistanceM / 1000.0, s.SpeedKmh, s.BrakePressBar, + YesNo(s.Current)); + } + + private static string YesNo(bool on) => on ? App.Loc["HaslerYes"] : App.Loc["HaslerNo"]; +} diff --git a/StarterNG/Views/Tapes/TapeView.cs b/StarterNG/Views/Tapes/TapeView.cs new file mode 100644 index 0000000..63ab02f --- /dev/null +++ b/StarterNG/Views/Tapes/TapeView.cs @@ -0,0 +1,202 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using StarterNG.Domain.Tapes; + +namespace StarterNG.Views.Tapes; + +/// +/// What every way of drawing a recorded trip shares: the trip, how it is laid +/// along the x axis - by distance or by time, at a scale - the clock it started +/// at, and the pointer read-out. +/// +public abstract class TapeView : Control +{ + public static readonly StyledProperty TapeProperty = + AvaloniaProperty.Register(nameof(Tape)); + + public static readonly StyledProperty ByDistanceProperty = + AvaloniaProperty.Register(nameof(ByDistance), true); + + /// Pixels per kilometre when fed by distance, per minute when fed by time. + public static readonly StyledProperty ScaleProperty = + AvaloniaProperty.Register(nameof(Scale), 40); + + /// + /// The time of day the recording started at. The log only counts seconds, so + /// without it the clock counts from the start of the trip. + /// + public static readonly StyledProperty StartClockProperty = + AvaloniaProperty.Register(nameof(StartClock)); + + private static readonly IPen CursorPen = new Pen(new SolidColorBrush(Color.Parse("#FFB000")), 1); + + static TapeView() + { + AffectsMeasure(TapeProperty, ByDistanceProperty, ScaleProperty); + AffectsRender(TapeProperty, ByDistanceProperty, ScaleProperty, StartClockProperty); + } + + public HaslerTape? Tape + { + get => GetValue(TapeProperty); + set => SetValue(TapeProperty, value); + } + + public bool ByDistance + { + get => GetValue(ByDistanceProperty); + set => SetValue(ByDistanceProperty, value); + } + + public double Scale + { + get => GetValue(ScaleProperty); + set => SetValue(ScaleProperty, value); + } + + public TimeSpan StartClock + { + get => GetValue(StartClockProperty); + set => SetValue(StartClockProperty, value); + } + + /// The sample under the pointer, or null once it leaves the tape. + public event Action? Hovered; + + protected abstract double LeftMargin { get; } + + private const double RightMargin = 24; + + protected abstract double TapeHeight { get; } + + protected double? CursorX { get; private set; } + + protected double TopSpeed => TapeFigures.TopSpeed(Tape?.MaxSpeedKmh ?? 0); + + protected double Length => Tape is null ? 0 : ByDistance ? Tape.DistanceM / 1000.0 : Tape.Duration / 60.0; + + protected double TapeRight => LeftMargin + Length * Scale; + + protected double XOf(TapeSample s) => + LeftMargin + (ByDistance ? s.DistanceM / 1000.0 : (s.Time - (Tape?.StartTime ?? 0)) / 60.0) * Scale; + + private double PositionOf(double x) => + ByDistance ? (x - LeftMargin) / Scale * 1000.0 : (x - LeftMargin) / Scale * 60.0 + (Tape?.StartTime ?? 0); + + /// Seconds since midnight at the sample, by the clock the trip started at. + public double ClockAt(TapeSample s) => StartClock.TotalSeconds + s.Time - (Tape?.StartTime ?? 0); + + /// What the view shows, for another one to show the same. + public void CopySettingsFrom(TapeView other) + { + Tape = other.Tape; + ByDistance = other.ByDistance; + Scale = other.Scale; + StartClock = other.StartClock; + } + + protected override Size MeasureOverride(Size availableSize) => + new(TapeRight + RightMargin, TapeHeight); + + public sealed override void Render(DrawingContext context) + { + DrawBlank(context); + + var tape = Tape; + if (tape is null || tape.IsEmpty) + return; + + DrawTape(context, tape); + DrawCursor(context); + } + + /// What is there with no trip on it. + protected abstract void DrawBlank(DrawingContext context); + + /// A trip with something in it, over the blank. + protected abstract void DrawTape(DrawingContext context, HaslerTape tape); + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var tape = Tape; + if (tape is null || tape.IsEmpty) + return; + + double x = Math.Clamp(e.GetPosition(this).X, LeftMargin, TapeRight); + int index = tape.IndexAt(PositionOf(x), ByDistance); + CursorX = x; + InvalidateVisual(); + Hovered?.Invoke(index < 0 ? null : tape.Samples[index]); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + CursorX = null; + InvalidateVisual(); + Hovered?.Invoke(null); + } + + private void DrawCursor(DrawingContext context) + { + if (CursorX is { } x) + context.DrawLine(CursorPen, new Point(x, 0), new Point(x, TapeHeight)); + } + + /// + /// A record as one line, one column of pixels at a time: per column the + /// lowest and highest point in it, so a long trip draws as fast as a short + /// one and what happens during a stop on a distance-fed tape still shows as + /// one stroke. + /// + protected StreamGeometry Trace(HaslerTape tape, Func y) + { + var geometry = new StreamGeometry(); + using var g = geometry.Open(); + bool begun = false; + int column = int.MinValue; + double top = 0, bottom = 0, first = 0, last = 0; + + void Flush() + { + if (column == int.MinValue) + return; + // Enter where the column started, leave where it ended. + var (a, b) = first <= last ? (top, bottom) : (bottom, top); + foreach (double v in new[] { first, a, b, last }) + { + var p = new Point(column, v); + if (!begun) + { + g.BeginFigure(p, false); + begun = true; + } + else + g.LineTo(p); + } + } + + foreach (var s in tape.Samples) + { + int c = (int)Math.Round(XOf(s)); + double value = y(s); + if (c != column) + { + Flush(); + column = c; + top = bottom = first = value; + } + top = Math.Min(top, value); + bottom = Math.Max(bottom, value); + last = value; + } + Flush(); + if (begun) + g.EndFigure(false); + return geometry; + } +}