feat(launcher): LU8 — logging in IS the character refresh; put Play above the fold

Two things the user hit while gating LU7.

1. "Refresh characters" disconnected the session they were playing. It opened a
   SECOND connection to the same account purely to read the roster, which the
   server treats as a new login — so refreshing while logged in kicked them
   out. It was also redundant the whole time: every ordinary login already
   carries the roster in the host's own status stream, and the orchestrator
   already folds it into the profiles (ApplyRosterLocked runs for play sessions,
   not just probes). Removed, along with "Add cached character", which existed
   to paper over a roster the launcher can now always obtain by itself. The
   account page says what happens instead: characters appear after you log in.

   ProbeAsync stays in Core — headless bots and the CLI use it, and it has its
   own tests. What is gone is offering it to a player as a button whose only
   effect they could observe was being disconnected.

   AnOrdinaryLoginFoldsTheReportedRosterIntoTheStore pins the replacement,
   including that it persists so the tree is still populated after a restart.

2. "There is no headless or gui option" under a selected character. The buttons
   were there — below the fold. The character page led with a plugins/login-
   commands form whose two 96px text boxes pushed the Launch card past the
   bottom of the scroll area, so the primary action was invisible unless you
   scrolled. Launch now comes first and the settings form sits under it. A
   player should never have to scroll to find Play.

Full solution 14,374 passed, 0 failed under the release-gate filter.

Not pushed — the user is testing locally first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 20:48:15 +02:00
parent 2bff44a9fa
commit 18bbd37779
4 changed files with 101 additions and 101 deletions

View file

@ -135,21 +135,23 @@
IsVisible="{Binding IsAccountSelected}">
<StackPanel Spacing="8">
<TextBlock Text="Account profile" Classes="section" />
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
<TextBlock Text="Your characters appear here automatically after you log in."
TextWrapping="Wrap" />
<!-- LU8: "Refresh characters" and "Add cached character" are
gone. Refresh opened a SECOND connection to the same
account purely to read the roster, which the server treats
as a new login and which therefore disconnected the
session the user was already playing. It was also
redundant: every ordinary login already carries the
roster in the host's own status stream, and the
orchestrator already merges it into the profiles
(ApplyRosterLocked). Logging in IS the refresh. -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Open character select"
Classes="primary"
AutomationProperties.Name="Open character select for account"
Command="{Binding LaunchAccountGuiSelectCommand}" />
<Button Content="Refresh characters"
Command="{Binding RefreshCharactersCommand}" />
<Button Content="Add cached character"
Command="{Binding AddCharacterCommand}" />
</StackPanel>
<TextBlock Text="{Binding ProbeDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
Classes="muted"
TextWrapping="Wrap" />
@ -157,6 +159,36 @@
</Border>
<StackPanel IsVisible="{Binding IsCharacterSelected}" Spacing="14">
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Launch" Classes="section" />
<!-- LU7: choosing a character means choosing to play AS that
character, so the only two things offered here are the
two ways to be that character: in the game window, or
headless. Character select is an ACCOUNT-level action —
it deliberately picks no character — and lives on the
account page only. -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Play"
Classes="primary"
MinWidth="110"
AutomationProperties.Name="Play as the selected character"
Command="{Binding LaunchGuiCommand}" />
<Button Content="Headless"
AutomationProperties.Name="Launch a headless session"
Command="{Binding LaunchHeadlessCommand}" />
</StackPanel>
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
</StackPanel>
</Border>
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Per-character launch settings" Classes="section" />
@ -189,36 +221,6 @@
Command="{Binding SaveCharacterSettingsCommand}" />
</StackPanel>
</Border>
<Border Background="#213044" CornerRadius="6" Padding="12">
<StackPanel Spacing="10">
<TextBlock Text="Launch" Classes="section" />
<!-- LU7: choosing a character means choosing to play AS that
character, so the only two things offered here are the
two ways to be that character: in the game window, or
headless. Character select is an ACCOUNT-level action —
it deliberately picks no character — and lives on the
account page only. -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Play"
Classes="primary"
MinWidth="110"
AutomationProperties.Name="Play as the selected character"
Command="{Binding LaunchGuiCommand}" />
<Button Content="Headless"
AutomationProperties.Name="Launch a headless session"
Command="{Binding LaunchHeadlessCommand}" />
</StackPanel>
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
Classes="muted"
TextWrapping="Wrap"
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>

View file

@ -65,9 +65,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
SaveCharacterSettingsCommand = new RelayCommand(
SaveCharacterSettings,
() => IsCharacterSelected && CanInteract);
RefreshCharactersCommand = new AsyncRelayCommand(
RefreshCharactersAsync,
() => CanProbe);
LaunchGuiCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.Gui),
() => CanLaunchGui);
@ -114,7 +111,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
if (SetProperty(ref _isBusy, value))
{
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
@ -199,25 +195,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public string LinuxGraphicalNotice =>
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
public bool CanProbe =>
CanInteract
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
public string ProbeDisabledReason
{
get
{
if (!TryGetSelectedAccount(out string server, out string account))
{
return "Select an account or one of its characters.";
}
return _orchestrator.GetProbeCapability(server, account).Reason
?? "Character refresh is available.";
}
}
public bool CanLaunchGui => CanLaunch(LaunchMode.Gui);
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
@ -277,8 +254,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public RelayCommand SaveCharacterSettingsCommand { get; }
public AsyncRelayCommand RefreshCharactersCommand { get; }
public AsyncRelayCommand LaunchGuiCommand { get; }
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
@ -365,7 +340,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
OnPropertyChanged(nameof(IsModalOpen));
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
@ -422,8 +396,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(InstallationStatus));
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
OnPropertyChanged(nameof(LinuxGraphicalNotice));
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(ProbeDisabledReason));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
@ -461,8 +433,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
LoadCharacterDraft();
}
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(ProbeDisabledReason));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
@ -728,19 +698,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
}
private Task RefreshCharactersAsync()
{
if (!TryGetSelectedAccount(out string serverName, out string accountName))
{
return Task.CompletedTask;
}
return RunOperationAsync(
token => _orchestrator.ProbeAsync(serverName, accountName, token),
$"Refreshing characters for {accountName}…",
"Character refresh started. The roster will update from host status.");
}
private Task LaunchSelectedAsync(LaunchMode mode)
{
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
@ -1049,7 +1006,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
EditSelectedCommand.NotifyCanExecuteChanged();
RemoveSelectedCommand.NotifyCanExecuteChanged();
SaveCharacterSettingsCommand.NotifyCanExecuteChanged();
RefreshCharactersCommand.NotifyCanExecuteChanged();
LaunchGuiCommand.NotifyCanExecuteChanged();
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
LaunchHeadlessCommand.NotifyCanExecuteChanged();

View file

@ -326,6 +326,61 @@ public sealed class LauncherOrchestratorTests : IDisposable
Assert.Single(orchestrator.GetSnapshot().Sessions);
}
/// <summary>
/// LU8: the launcher no longer offers "Refresh characters", because opening
/// a SECOND connection to an account purely to read its roster is a login
/// as far as the server is concerned, and it disconnected the session the
/// user was already playing. This is what replaces it — and it is not new
/// code, it is the path an ORDINARY login already took: the host reports
/// the roster in its own status stream and the orchestrator folds it into
/// the profiles. Logging in IS the refresh.
/// </summary>
[Fact]
public async Task AnOrdinaryLoginFoldsTheReportedRosterIntoTheStore()
{
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
statusSourceFactory: statusSources);
// No cached character, so this is the account-level character-select
// launch a new user makes before any character is known.
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
characterName: null,
LaunchMode.GuiSelect);
QueueStatusSource source = Assert.Single(statusSources.Created);
source.Enqueue(new CharacterListStatusEvent
{
V = 1,
E = "characterList",
T = DateTimeOffset.UtcNow,
SessionId = "s1",
AccountName = "testaccount",
SlotCount = 6,
Characters =
[
new StatusCharacterEntry(0x5000000Au, "+Acdream", 0),
new StatusCharacterEntry(0x5000000Bu, "+Second", 0),
],
});
orchestrator.PollStatus();
LauncherAccountSnapshot account = Assert.Single(
Assert.Single(orchestrator.GetSnapshot().Servers).Accounts);
Assert.Equal(2, account.Characters.Count);
// Persisted, so the tree still shows them on the next launcher start.
var reloaded = new LauncherProfileStore(
Path.Combine(_paths.ConfigDirectory, "launcher-profiles.json"));
Assert.True(reloaded.Load());
Assert.Equal(
2,
reloaded.Document.Servers.Single().Accounts.Single().Characters.Count);
}
[Fact]
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
{

View file

@ -189,25 +189,12 @@ public sealed class LauncherWindowViewModelTests
viewModel.OperationStatus);
}
[Fact]
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = CreateInitialized(orchestrator);
SelectAccount(viewModel);
Assert.True(viewModel.CanProbe);
await viewModel.RefreshCharactersCommand.ExecuteAsync();
Assert.Equal(("Local ACE", "testaccount"), orchestrator.ProbeRequest);
orchestrator.ProbeCapability = LauncherCapability.Unavailable(
"Stop the active session before refreshing this account.");
orchestrator.RaiseStateChanged();
SelectAccount(viewModel);
Assert.False(viewModel.CanProbe);
Assert.Contains("Stop", viewModel.ProbeDisabledReason, StringComparison.Ordinal);
}
// LU8: the probe-driven "Refresh characters" action is gone. It opened a
// SECOND connection to an account purely to read the roster, which the
// server treats as a new login and which disconnected a session the user
// was already playing. Its test goes with it rather than being skipped;
// the roster path that replaced it is covered by
// LauncherOrchestrator's roster merge on an ordinary login.
[Fact]
public void LinuxKeepsLauncherAndHeadlessAvailableButExplainsDisabledGuiModes()