mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
Extract starting the simulator into StartSimulation
The click handler is left with asking the user things and showing the result.
This commit is contained in:
@@ -57,6 +57,7 @@ public sealed class AppServices
|
||||
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
|
||||
SettingsPaths = settingsPaths;
|
||||
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
|
||||
StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -128,6 +129,9 @@ public sealed class AppServices
|
||||
|
||||
public MissingVehicleLog MissingVehicleLog { get; }
|
||||
|
||||
/// <summary>Starts the simulator on the current selection.</summary>
|
||||
public StartSimulation StartSimulation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
|
||||
/// because the XAML binds to it as a change-notifying source; code should take
|
||||
|
||||
188
StarterNG/Application/StartSimulation.cs
Normal file
188
StarterNG/Application/StartSimulation.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>Why a start attempt ended the way it did.</summary>
|
||||
public enum SimulationStartOutcome
|
||||
{
|
||||
Started,
|
||||
|
||||
/// <summary>Nothing is selected to run.</summary>
|
||||
NothingSelected,
|
||||
|
||||
/// <summary>The scenario could not be written for the simulator to read.</summary>
|
||||
ExportFailed,
|
||||
|
||||
/// <summary>The executable is missing, not executable, or built for another platform.</summary>
|
||||
ExecutableProblem,
|
||||
|
||||
/// <summary>The executable is fine but the process would not start.</summary>
|
||||
LaunchFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What happened, and what the caller needs in order to say so or to watch the
|
||||
/// simulator it just started.
|
||||
/// </summary>
|
||||
public readonly record struct SimulationStartResult(
|
||||
SimulationStartOutcome Outcome,
|
||||
IProcessHandle? Process = null,
|
||||
string ExecutablePath = "",
|
||||
ExeProblem Problem = ExeProblem.None,
|
||||
string? Detail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the simulator on the scenario and consist the user has selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This was the middle of a 100-line click handler that also drove dialogs and
|
||||
/// the loading screen. The sequence itself - settle the consist's starting
|
||||
/// state, write the scenario the simulator will read, save the settings, launch -
|
||||
/// is application logic and belongs away from the window; what is left in the
|
||||
/// view is asking the user things and showing what came back.
|
||||
/// </remarks>
|
||||
public sealed class StartSimulation
|
||||
{
|
||||
/// <summary>
|
||||
/// A scenario the starter has rewritten is saved beside the original under a
|
||||
/// '$' name, which the simulator loads and the scenery list ignores.
|
||||
/// </summary>
|
||||
private const char ExportPrefix = '$';
|
||||
|
||||
/// <summary>Battery voltage stood in for by a token velocity, as the format demands.</summary>
|
||||
private const float BatteryOnVelocity = 0.1f;
|
||||
|
||||
private readonly AppState _state;
|
||||
private readonly SettingsStore _settings;
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IProcessLauncher _processes;
|
||||
private readonly IRandomSource _random;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
|
||||
public StartSimulation(AppState state, SettingsStore settings, IFileSystem files, IProcessLauncher processes,
|
||||
IRandomSource random, IDiagnosticsLog log)
|
||||
{
|
||||
_state = state;
|
||||
_settings = settings;
|
||||
_files = files;
|
||||
_processes = processes;
|
||||
_random = random;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The vehicle the player would start in, or null when the consist has no one
|
||||
/// aboard - which is what the caller warns about before committing.
|
||||
/// </summary>
|
||||
public static string? StartableVehicle(Trainset? trainset, string? preferred)
|
||||
{
|
||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
|
||||
static bool CanStart(Dynamic vehicle) =>
|
||||
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
if (!string.IsNullOrEmpty(preferred))
|
||||
{
|
||||
var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
|
||||
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
|
||||
if (picked is not null && CanStart(picked))
|
||||
return picked.Name;
|
||||
}
|
||||
|
||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
|
||||
}
|
||||
|
||||
/// <param name="freeFly">Start as a free camera rather than in a vehicle.</param>
|
||||
/// <param name="saveSettings">Write the settings before launching.</param>
|
||||
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
|
||||
{
|
||||
var scenery = _state.CurrentScenery;
|
||||
var trainset = _state.CurrentTrainset;
|
||||
if (scenery is null || trainset is null)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
|
||||
ApplyBatteryDefault(trainset);
|
||||
|
||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName)
|
||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
||||
_state.StartingVehicleName = vehicle;
|
||||
|
||||
// The export always rewrites the weather block, so a run reflects what the
|
||||
// weather tab shows even when the user never touched it.
|
||||
scenery.Weather.Dirty = true;
|
||||
|
||||
string exportName = ExportPrefix + Path.GetFileName(scenery.Path);
|
||||
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
|
||||
try
|
||||
{
|
||||
_files.WriteAllText(exportPath,
|
||||
scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random),
|
||||
LegacyEncoding());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"writing {exportPath}", ex);
|
||||
return new SimulationStartResult(SimulationStartOutcome.ExportFailed, Detail: ex.Message,
|
||||
ExecutablePath: exportPath);
|
||||
}
|
||||
|
||||
if (saveSettings)
|
||||
_settings.CaptureAndSave();
|
||||
|
||||
string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem));
|
||||
if (problem != ExeProblem.None)
|
||||
return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable,
|
||||
Problem: problem);
|
||||
|
||||
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
||||
? new[] { "-s", exportName }
|
||||
: new[] { "-s", exportName, "-v", vehicle };
|
||||
|
||||
var process = _processes.Start(executable, arguments, Path.GetDirectoryName(executable), out string? error);
|
||||
if (process is null)
|
||||
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
||||
Detail: error);
|
||||
|
||||
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the user's battery preference to the consist. The scenery format
|
||||
/// carries the battery state in the trainset's velocity field, so "always on"
|
||||
/// means a token non-zero value when the consist is standing still.
|
||||
/// </summary>
|
||||
private void ApplyBatteryDefault(Trainset trainset)
|
||||
{
|
||||
if (trainset.Vehicles.Count == 0)
|
||||
return;
|
||||
|
||||
trainset.Velocity = _settings.Settings.BatteryDefault switch
|
||||
{
|
||||
BatteryDefault.AlwaysOff => 0f,
|
||||
BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f
|
||||
? BatteryOnVelocity
|
||||
: trainset.OriginalVelocity,
|
||||
_ => trainset.OriginalVelocity
|
||||
};
|
||||
}
|
||||
|
||||
private static Encoding LegacyEncoding()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Encoding.GetEncoding(1250);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Encoding.Latin1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ using StarterNG.Infrastructure;
|
||||
using StarterNG.Services;
|
||||
using StarterNG.Views;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
@@ -413,13 +414,15 @@ public partial class MainWindow : Window
|
||||
menu.Open(startButton);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the start use case and turns whatever it reports into something the
|
||||
/// user sees. Everything that is not a dialog lives in <see cref="StartSimulation"/>.
|
||||
/// </summary>
|
||||
private async Task LaunchAsync(bool saveSettings, bool freeFly)
|
||||
{
|
||||
var scenery = AppServices.Current.State.CurrentScenery;
|
||||
if (scenery is null)
|
||||
return;
|
||||
var trainset = AppServices.Current.State.CurrentTrainset;
|
||||
if (trainset is null)
|
||||
if (scenery is null || trainset is null)
|
||||
return;
|
||||
|
||||
if (!freeFly && !HasStartableVehicle(trainset) &&
|
||||
@@ -427,65 +430,28 @@ public partial class MainWindow : Window
|
||||
App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
|
||||
return;
|
||||
|
||||
if (trainset.Vehicles.Count > 0)
|
||||
{
|
||||
trainset.Velocity = AppServices.Current.Settings.BatteryDefault switch
|
||||
{
|
||||
BatteryDefault.AlwaysOff => 0f,
|
||||
BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f ? 0.1f : trainset.OriginalVelocity,
|
||||
_ => trainset.OriginalVelocity
|
||||
};
|
||||
}
|
||||
|
||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(
|
||||
trainset, scenery, AppServices.Current.State.StartingVehicleName);
|
||||
if (string.IsNullOrEmpty(vehicle))
|
||||
vehicle = ResolveStartVehicle(trainset);
|
||||
AppServices.Current.State.StartingVehicleName = vehicle;
|
||||
|
||||
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
||||
|
||||
scenery.Weather.Dirty = true;
|
||||
var result = AppServices.Current.StartSimulation.Execute(freeFly, saveSettings);
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case SimulationStartOutcome.NothingSelected:
|
||||
return;
|
||||
|
||||
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
|
||||
string exportName = "$" + Path.GetFileName(scenery.Path);
|
||||
string exportPath = Path.Combine(dir, exportName);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(exportPath, scenery.BuildExportContent(AppServices.Current.Settings.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Diagnostics.ReportAsync($"{exportPath}", ex, App.Loc["FaultTitle"]);
|
||||
return;
|
||||
}
|
||||
case SimulationStartOutcome.ExportFailed:
|
||||
await Diagnostics.ReportAsync(
|
||||
$"{result.ExecutablePath}{Environment.NewLine}{Environment.NewLine}{result.Detail}",
|
||||
App.Loc["FaultTitle"]);
|
||||
return;
|
||||
|
||||
if (saveSettings)
|
||||
AppServices.Current.SettingsStore.CaptureAndSave();
|
||||
case SimulationStartOutcome.ExecutableProblem:
|
||||
await ShowExeProblem(result.Problem, result.ExecutablePath);
|
||||
return;
|
||||
|
||||
string exe = Path.GetFullPath(AppServices.Current.SettingsStore.ResolveExecutable(out var exeProblem));
|
||||
if (exeProblem != ExeProblem.None)
|
||||
{
|
||||
await ShowExeProblem(exeProblem, exe);
|
||||
return;
|
||||
}
|
||||
|
||||
Process? sim;
|
||||
try
|
||||
{
|
||||
sim = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
Arguments = freeFly ? $"-s {exportName}" : $"-s {exportName} -v {vehicle}",
|
||||
WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(),
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.Show(this, $"{exe}\n\n{ex.Message}",
|
||||
App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok);
|
||||
return;
|
||||
case SimulationStartOutcome.LaunchFailed:
|
||||
await MessageBox.Show(this, $"{result.ExecutablePath}\n\n{result.Detail}",
|
||||
App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok);
|
||||
return;
|
||||
}
|
||||
|
||||
if (AppServices.Current.Settings.AutoCloseStarter)
|
||||
@@ -494,61 +460,30 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
// The starter sits idle while the simulator runs; give the memory back.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
WatchSimulator(sim);
|
||||
WatchSimulator(result.Process);
|
||||
}
|
||||
|
||||
private static string? ResolveStartVehicle(Trainset? trainset)
|
||||
/// <summary>
|
||||
/// Brings the starter back when the simulator exits. Adopts an already
|
||||
/// running simulator when we did not start it ourselves.
|
||||
/// </summary>
|
||||
private void WatchSimulator(IProcessHandle? simulator)
|
||||
{
|
||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
simulator ??= AppServices.Current.Processes.FindRunning(
|
||||
Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable()));
|
||||
|
||||
static bool CanStart(Dynamic v) =>
|
||||
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
string? selected = AppServices.Current.State.StartingVehicleName;
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
if (simulator is null)
|
||||
{
|
||||
var pick = trainset.Vehicles.FirstOrDefault(v =>
|
||||
string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase));
|
||||
if (pick != null && CanStart(pick))
|
||||
return pick.Name;
|
||||
}
|
||||
|
||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name
|
||||
?? trainset.Vehicles.FirstOrDefault()?.Name;
|
||||
}
|
||||
|
||||
private void WatchSimulator(Process? sim)
|
||||
{
|
||||
if (sim is null)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable());
|
||||
sim = Process.GetProcessesByName(name).FirstOrDefault();
|
||||
}
|
||||
|
||||
if (sim is null)
|
||||
{
|
||||
|
||||
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
||||
return;
|
||||
}
|
||||
|
||||
var watcher = new Thread(() =>
|
||||
{
|
||||
try { sim.WaitForExit(); }
|
||||
catch
|
||||
{
|
||||
}
|
||||
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SimulatorWatcher"
|
||||
};
|
||||
watcher.Start();
|
||||
_ = simulator.WaitForExitAsync().ContinueWith(
|
||||
_ => Dispatcher.UIThread.Post(RestoreFromSimulator), TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private async void CheckExternalSettings()
|
||||
|
||||
Reference in New Issue
Block a user