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:
parent
0a2defb618
commit
09305be6c6
5 changed files with 234 additions and 20 deletions
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
123
tests/AcDream.Launcher.Tests/LauncherSessionRowViewModelTests.cs
Normal file
123
tests/AcDream.Launcher.Tests/LauncherSessionRowViewModelTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// LU6. A sessions row answers "who is playing, and are they in yet?" — not
|
||||
/// "which launch mode started this process".
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Once the host reports who entered the world, the orchestrator fills the
|
||||
/// name in — so a character-select session stops being anonymous.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue