feat(launcher): LU5/LU6 — one Play button per character, and sessions say who is playing

LU5. The per-character panel offered "GUI — enter world", "GUI — character
select" and "Headless" as three equal-looking buttons, above a "Default launch
mode" combo. It now leads with one primary **Play** that enters the world as
the selected character, with Character select and Headless kept as deliberate
secondary choices.

The combo is gone. It was never consulted by anything: every launch button
passes its own mode and LauncherOrchestrator.LaunchAsync overrides the
profile's stored mode with it (CloneCharacter(character, mode)). A setting that
changes nothing is worse than no setting, and this one made the three buttons
look like they obeyed it. The stored value is untouched.

Worth recording for whoever reads the LU5 acceptance: the launcher-side
plumbing was already correct end to end — orchestrator, selector composition,
and the client's own "skip character select when a selector is present" gate.
What actually made launching a character fail was #420, a client crash on the
character-select screen, fixed separately. Every play session in the user's
cache had no character selector, which is consistent with them only ever
reaching the select-screen paths.

LU6. Rows read `server / account / character`, then the launch mode
(Gui/GuiSelect/Headless/Probe), then the raw LauncherActivityState enum name,
then a status string. The launch mode is launcher bookkeeping — it says how the
process was started, which tells the person watching nothing and is meaningless
once the client is up.

Rows now show the account, the character (or "Character select" while one is
still being chosen, "Character refresh" for a roster probe), and one plain word
derived from the host's own status stream: Starting -> Character select ->
In game -> Stopping -> Stopped / Failed. A Play launch and a character-select
launch both read "In game" once the player is actually in it.

The orchestrator now KEEPS the identity from the host's enteredWorld event
instead of only formatting it into a status sentence, so a character-select
session stops being anonymous the moment someone enters the world.

Tests: LauncherSessionRowViewModelTests (16 — every state's wording, in-game
independent of launch mode, the character-select placeholder and its
replacement, probe labelling, stop gating). Full solution 14,370 passed,
0 failed, 0 skipped under the release-gate filter.

Campaign LU slices LU5 and LU6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 18:54:37 +02:00
parent 0a2defb618
commit 09305be6c6
5 changed files with 234 additions and 20 deletions

View file

@ -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; }
/// <summary>
/// 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.
/// </summary>
public string? CharacterName { get; set; }
public LaunchMode? LaunchMode { get; }

View file

@ -160,9 +160,14 @@
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Per-character launch settings" Classes="section" />
<TextBlock Text="Default launch mode" Classes="muted" />
<ComboBox ItemsSource="{Binding AvailableLaunchModes}"
SelectedItem="{Binding CharacterLaunchMode, Mode=TwoWay}" />
<!-- LU5: the "Default launch mode" combo used to sit here.
It was never consulted by anything — every launch button
passes its own mode and LauncherOrchestrator.LaunchAsync
overrides the profile's saved mode with it
(CloneCharacter(character, mode)). A setting that changes
nothing is worse than no setting, and it made the three
launch buttons below look like they obeyed it. The stored
value is left alone; it is simply no longer offered. -->
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="5">
<TextBlock Text="Plugins (one id per line)" Classes="muted" />
@ -188,13 +193,22 @@
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Launch" Classes="section" />
<!-- LU5: one obvious action. Play enters the world AS THE
SELECTED CHARACTER (LaunchMode.Gui composes a character
selector, and the client skips its own select screen
entirely when one is present). The other two stay as
deliberate, secondary choices. -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="GUI — enter world"
<Button Content="Play"
Classes="primary"
MinWidth="110"
AutomationProperties.Name="Play as the selected character"
Command="{Binding LaunchGuiCommand}" />
<Button Content="GUI — character select"
<Button Content="Character select"
AutomationProperties.Name="Launch to the character select screen"
Command="{Binding LaunchGuiSelectCommand}" />
<Button Content="Headless"
AutomationProperties.Name="Launch a headless session"
Command="{Binding LaunchHeadlessCommand}" />
</StackPanel>
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
@ -227,9 +241,17 @@
<Border BorderBrush="#344559"
BorderThickness="0,0,0,1"
Padding="4,8">
<Grid ColumnDefinitions="2*,90,90,3*,Auto">
<TextBlock Text="{Binding Target}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding Mode}" Classes="muted" />
<!-- LU6: account, character, then one plain word for
whether they are in the game. The launch mode is gone
from the row — it described how the session started,
which tells the person watching nothing. -->
<Grid ColumnDefinitions="2*,2*,130,3*,Auto">
<TextBlock Text="{Binding Account}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1"
Text="{Binding Character}"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Text="{Binding State}" />
<StackPanel Grid.Column="3" Spacing="2">
<TextBlock Text="{Binding Status}"

View file

@ -2,6 +2,17 @@ using AcDream.Launcher.Core.Orchestration;
namespace AcDream.Launcher.ViewModels;
/// <summary>
/// LU6: one line per running thing, in a player's terms — which account, which
/// character, and whether they are in the game.
///
/// <para>The row used to lead with the launch MODE (Gui / GuiSelect / Headless
/// / Probe) and the raw <see cref="LauncherActivityState"/> 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.</para>
/// </summary>
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; }
/// <summary>
/// The character being played, "Character select" while the player is
/// still choosing one, or "Character refresh" for a roster probe.
/// </summary>
public string Character { get; }
/// <summary>One plain word for what is happening. See
/// <see cref="DescribeState"/>.</summary>
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;
}
/// <summary>
/// Derived from the host's own status stream (through
/// <see cref="LauncherActivityState"/>), 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.
/// </summary>
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",
};
}