diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
index 9bb48560..f7923808 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
@@ -873,6 +873,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
activity.State = LauncherActivityState.InWorld;
}
+ // LU6: keep the identity, not just a sentence about it. A
+ // character-select launch has no character name until this
+ // arrives, and the sessions list should say who is playing.
+ if (!string.IsNullOrWhiteSpace(enteredWorld.CharacterName))
+ {
+ activity.CharacterName = enteredWorld.CharacterName;
+ }
activity.Status = $"In world as {enteredWorld.CharacterName}.";
break;
case PluginLoadedStatusEvent loaded:
@@ -1269,7 +1276,14 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public string AccountName { get; }
- public string? CharacterName { get; }
+ ///
+ /// LU6: settable so the character the player ACTUALLY entered the world
+ /// as can replace a null. A character-select launch starts with no
+ /// character name, and the host's own enteredWorld event is the only
+ /// place that identity ever becomes known — it used to be written into
+ /// a status string and thrown away.
+ ///
+ public string? CharacterName { get; set; }
public LaunchMode? LaunchMode { get; }
diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml
index 44526b0e..bb638338 100644
--- a/src/AcDream.Launcher/MainWindow.axaml
+++ b/src/AcDream.Launcher/MainWindow.axaml
@@ -160,9 +160,14 @@
-
-
+
@@ -188,13 +193,22 @@
+
-
-
-
-
-
+
+
+
+
+/// LU6: one line per running thing, in a player's terms — which account, which
+/// character, and whether they are in the game.
+///
+/// The row used to lead with the launch MODE (Gui / GuiSelect / Headless
+/// / Probe) and the raw enum name. The
+/// launch mode is launcher bookkeeping: it says how the session was started,
+/// which is not something the person watching the list cares about, and it is
+/// meaningless once the client is up. What they want to know is who is logged
+/// in and whether they made it into the world.
+///
public sealed class LauncherSessionRowViewModel
{
public LauncherSessionRowViewModel(
@@ -13,13 +24,10 @@ public sealed class LauncherSessionRowViewModel
ArgumentNullException.ThrowIfNull(stop);
SessionId = snapshot.SessionId;
- Target = snapshot.Kind == LauncherActivityKind.Probe
- ? $"{snapshot.ServerName} / {snapshot.AccountName} / character refresh"
- : $"{snapshot.ServerName} / {snapshot.AccountName} / {snapshot.CharacterName}";
- Mode = snapshot.Kind == LauncherActivityKind.Probe
- ? "Probe"
- : snapshot.LaunchMode?.ToString() ?? "Session";
- State = snapshot.State.ToString();
+ Account = snapshot.AccountName;
+ Server = snapshot.ServerName;
+ Character = DescribeCharacter(snapshot);
+ State = DescribeState(snapshot);
Status = snapshot.Status;
Error = snapshot.Error;
IsActive = snapshot.IsActive;
@@ -30,10 +38,18 @@ public sealed class LauncherSessionRowViewModel
public string SessionId { get; }
- public string Target { get; }
+ public string Account { get; }
- public string Mode { get; }
+ public string Server { get; }
+ ///
+ /// The character being played, "Character select" while the player is
+ /// still choosing one, or "Character refresh" for a roster probe.
+ ///
+ public string Character { get; }
+
+ /// One plain word for what is happening. See
+ /// .
public string State { get; }
public string Status { get; }
@@ -47,4 +63,40 @@ public sealed class LauncherSessionRowViewModel
public AsyncRelayCommand StopCommand { get; }
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
+
+ private static string DescribeCharacter(LauncherSessionSnapshot snapshot)
+ {
+ if (snapshot.Kind == LauncherActivityKind.Probe)
+ {
+ return "Character refresh";
+ }
+
+ // A character-select launch carries no character until the host's
+ // enteredWorld event supplies one, which the orchestrator now keeps.
+ return string.IsNullOrWhiteSpace(snapshot.CharacterName)
+ ? "Character select"
+ : snapshot.CharacterName;
+ }
+
+ ///
+ /// Derived from the host's own status stream (through
+ /// ), never from the launch mode:
+ /// a session launched straight into the world and one launched to
+ /// character select both read "In game" once the player is actually in it.
+ ///
+ private static string DescribeState(LauncherSessionSnapshot snapshot) =>
+ snapshot.State switch
+ {
+ LauncherActivityState.Starting or LauncherActivityState.Running =>
+ "Starting",
+ LauncherActivityState.Connected => snapshot.Kind
+ == LauncherActivityKind.Probe
+ ? "Reading characters"
+ : "Character select",
+ LauncherActivityState.InWorld => "In game",
+ LauncherActivityState.Disconnected or LauncherActivityState.Stopping =>
+ "Stopping",
+ LauncherActivityState.Failed => "Failed",
+ _ => "Stopped",
+ };
}
diff --git a/tests/AcDream.Launcher.Tests/LauncherSessionRowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherSessionRowViewModelTests.cs
new file mode 100644
index 00000000..832cda5c
--- /dev/null
+++ b/tests/AcDream.Launcher.Tests/LauncherSessionRowViewModelTests.cs
@@ -0,0 +1,123 @@
+using AcDream.Launcher.Core.Launching;
+using AcDream.Launcher.Core.Profiles;
+using AcDream.Launcher.Core.Orchestration;
+using AcDream.Launcher.ViewModels;
+
+namespace AcDream.Launcher.Tests;
+
+///
+/// LU6. A sessions row answers "who is playing, and are they in yet?" — not
+/// "which launch mode started this process".
+///
+public sealed class LauncherSessionRowViewModelTests
+{
+ [Theory]
+ [InlineData(LauncherActivityState.Starting, "Starting")]
+ [InlineData(LauncherActivityState.Running, "Starting")]
+ [InlineData(LauncherActivityState.Connected, "Character select")]
+ [InlineData(LauncherActivityState.InWorld, "In game")]
+ [InlineData(LauncherActivityState.Disconnected, "Stopping")]
+ [InlineData(LauncherActivityState.Stopping, "Stopping")]
+ [InlineData(LauncherActivityState.Exited, "Stopped")]
+ [InlineData(LauncherActivityState.Cancelled, "Stopped")]
+ [InlineData(LauncherActivityState.Failed, "Failed")]
+ public void StateReadsAsPlainEnglish(LauncherActivityState state, string expected)
+ {
+ var row = new LauncherSessionRowViewModel(
+ Snapshot(state: state),
+ _ => Task.CompletedTask);
+
+ Assert.Equal(expected, row.State);
+ }
+
+ ///
+ /// The same wording regardless of how the session was started: a Play
+ /// launch and a character-select launch both read "In game" once the
+ /// player is actually in the world, because the state comes from the
+ /// host's own status stream rather than from the launch mode.
+ ///
+ [Theory]
+ [InlineData(LaunchMode.Gui)]
+ [InlineData(LaunchMode.GuiSelect)]
+ [InlineData(LaunchMode.Headless)]
+ public void InGameDoesNotDependOnHowTheSessionWasLaunched(LaunchMode mode)
+ {
+ var row = new LauncherSessionRowViewModel(
+ Snapshot(state: LauncherActivityState.InWorld, mode: mode),
+ _ => Task.CompletedTask);
+
+ Assert.Equal("In game", row.State);
+ }
+
+ [Fact]
+ public void ACharacterSelectLaunchSaysSoUntilACharacterIsKnown()
+ {
+ var row = new LauncherSessionRowViewModel(
+ Snapshot(character: null, mode: LaunchMode.GuiSelect),
+ _ => Task.CompletedTask);
+
+ Assert.Equal("Character select", row.Character);
+ Assert.Equal("testaccount", row.Account);
+ }
+
+ ///
+ /// Once the host reports who entered the world, the orchestrator fills the
+ /// name in — so a character-select session stops being anonymous.
+ ///
+ [Fact]
+ public void AnEnteredCharacterReplacesTheCharacterSelectPlaceholder()
+ {
+ var row = new LauncherSessionRowViewModel(
+ Snapshot(
+ character: "+alex",
+ mode: LaunchMode.GuiSelect,
+ state: LauncherActivityState.InWorld),
+ _ => Task.CompletedTask);
+
+ Assert.Equal("+alex", row.Character);
+ Assert.Equal("In game", row.State);
+ }
+
+ [Fact]
+ public void AProbeIsLabelledAsACharacterRefresh()
+ {
+ var row = new LauncherSessionRowViewModel(
+ Snapshot(kind: LauncherActivityKind.Probe, character: null, mode: null),
+ _ => Task.CompletedTask);
+
+ Assert.Equal("Character refresh", row.Character);
+ Assert.Equal("Reading characters", row.State);
+ }
+
+ [Fact]
+ public void StopIsOfferedOnlyWhileTheSessionIsActive()
+ {
+ var running = new LauncherSessionRowViewModel(
+ Snapshot(state: LauncherActivityState.InWorld),
+ _ => Task.CompletedTask);
+ var finished = new LauncherSessionRowViewModel(
+ Snapshot(state: LauncherActivityState.Exited),
+ _ => Task.CompletedTask);
+
+ Assert.True(running.StopCommand.CanExecute(null));
+ Assert.False(finished.StopCommand.CanExecute(null));
+ }
+
+ private static LauncherSessionSnapshot Snapshot(
+ LauncherActivityState state = LauncherActivityState.Connected,
+ LauncherActivityKind kind = LauncherActivityKind.Play,
+ string? character = "+Acdream",
+ LaunchMode? mode = LaunchMode.Gui,
+ string status = "Fixture status.") => new(
+ "session-1",
+ kind,
+ "Local ACE",
+ "testaccount",
+ character,
+ mode,
+ state,
+ status,
+ ExitCode: state == LauncherActivityState.Exited ? 0 : null,
+ Error: null,
+ CreatedAt: DateTimeOffset.UnixEpoch);
+}
diff --git a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
index 849e5a7c..d33d2603 100644
--- a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
@@ -28,8 +28,11 @@ public sealed class LauncherWindowViewModelTests
Assert.Same(server, viewModel.SelectedNode);
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
- Assert.Equal("Gui", session.Mode);
- Assert.Equal("Connected", session.State);
+ // LU6: rows read as account / character / plain state. The launch mode
+ // is gone, and "Connected" is shown as what it means to a player.
+ Assert.Equal("testaccount", session.Account);
+ Assert.Equal("+Acdream", session.Character);
+ Assert.Equal("Character select", session.State);
Assert.True(session.IsActive);
Assert.True(viewModel.IsFirstRunRequired);