fix: launcher-started headless sessions and character refresh never ran at all
The launcher spawned the headless host as
acdream-headless --config <path>
but HeadlessCommandLine.Parse reads arguments[0] as the COMMAND and accepts
only "validate" or "run". So every launcher-started headless session and every
"Refresh characters" died on its first instruction with
Invalid command. Run --help for usage. (exit 64)
The user's own cache shows it six times over two days. It was invisible because
the failure is an exit code in a status file, not something the UI says out
loud — which is how it survived a whole campaign whose gates exercised the
headless host through its CLI directly, never through the launcher's spec.
The graphical host takes a bare "--session-config" and has no command word;
this sibling call was written to match it. Both headless call sites now pass
"run" first. A probe is an ordinary "run" whose session config carries
mode: "probe" — the difference is in the document, not the command line, so
one fix repairs refresh and headless play together.
LauncherHeadlessCommandLineContractTests is the connection that was missing:
it takes the argument vector the launcher will really use and hands it to the
parser the host will really use, for probe and for headless play, and pins that
the graphical arguments are deliberately NOT a headless command line. The two
sides cannot drift again without failing here. Headless.Tests already
referenced both assemblies, so this needed no new coupling.
Also LU7, at the user's direction: a selected character now offers only Play
and Headless. Choosing a character means choosing to play AS that character, so
"Character select" — which deliberately picks no character — belongs to the
account page alone, where it already lives. The per-character GuiSelect command
and its capability are removed rather than left as dead surface.
Full solution 14,374 passed, 0 failed under the release-gate filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
955c618013
commit
2bff44a9fa
5 changed files with 117 additions and 23 deletions
|
|
@ -100,7 +100,14 @@ public sealed class LauncherExecutableSet
|
|||
return mode == LaunchMode.Headless
|
||||
? new LauncherProcessSpec(
|
||||
paths.HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
// "run" is REQUIRED and positional: HeadlessCommandLine.Parse
|
||||
// reads arguments[0] as the command and accepts only
|
||||
// "validate" or "run". Passing "--config" first made every
|
||||
// launcher-started headless session die instantly with
|
||||
// "Invalid command. Run --help for usage." (exit 64) — the
|
||||
// graphical host takes a bare "--session-config", and this
|
||||
// sibling call was written to match it.
|
||||
["run", "--config", configFilePath],
|
||||
paths.WorkingDirectory,
|
||||
StderrLogPath: stderrLogPath)
|
||||
: new LauncherProcessSpec(
|
||||
|
|
@ -119,7 +126,11 @@ public sealed class LauncherExecutableSet
|
|||
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
|
||||
return new LauncherProcessSpec(
|
||||
paths.HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
// Same grammar as CreatePlaySpec's headless arm — see its comment.
|
||||
// A probe is an ordinary "run" whose session config carries
|
||||
// mode: "probe"; the difference is in the document, not the
|
||||
// command line.
|
||||
["run", "--config", configFilePath],
|
||||
paths.WorkingDirectory,
|
||||
StderrLogPath: stderrLogPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,20 +193,18 @@
|
|||
<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. -->
|
||||
<!-- 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="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}" />
|
||||
|
|
|
|||
|
|
@ -71,9 +71,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
LaunchGuiCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.Gui),
|
||||
() => CanLaunchGui);
|
||||
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
||||
() => CanLaunchGuiSelect);
|
||||
LaunchAccountGuiSelectCommand = new AsyncRelayCommand(
|
||||
LaunchSelectedAccountGuiSelectAsync,
|
||||
() => CanLaunchAccountGuiSelect);
|
||||
|
|
@ -119,7 +116,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
|
|
@ -224,8 +220,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public bool CanLaunchGui => CanLaunch(LaunchMode.Gui);
|
||||
|
||||
public bool CanLaunchGuiSelect => CanLaunch(LaunchMode.GuiSelect);
|
||||
|
||||
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
||||
|
||||
public bool CanLaunchAccountGuiSelect =>
|
||||
|
|
@ -287,8 +281,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public AsyncRelayCommand LaunchGuiCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||
|
|
@ -375,7 +367,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(IsModalOpen));
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
|
|
@ -434,7 +425,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||
|
|
@ -474,7 +464,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(ProbeDisabledReason));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||
|
|
@ -1062,7 +1051,6 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
SaveCharacterSettingsCommand.NotifyCanExecuteChanged();
|
||||
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
||||
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
|
||||
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The launcher spawns the headless host; the headless host decides what a
|
||||
/// valid command line is. Nothing connected those two facts, and they drifted:
|
||||
/// the launcher passed <c>--config <path></c> while
|
||||
/// <see cref="HeadlessCommandLine.Parse"/> reads <c>arguments[0]</c> as the
|
||||
/// command and accepts only <c>validate</c> or <c>run</c>. Every
|
||||
/// launcher-started headless session and every character refresh therefore died
|
||||
/// instantly with "Invalid command. Run --help for usage." — visible only as an
|
||||
/// exit code in a status file, which is why it survived a whole campaign.
|
||||
///
|
||||
/// <para>These tests are the missing connection: they take the argument vector
|
||||
/// the launcher will really use and hand it to the parser the host will really
|
||||
/// use. The two cannot drift again without failing here.</para>
|
||||
/// </summary>
|
||||
public sealed class LauncherHeadlessCommandLineContractTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-headless-cli-contract",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LauncherHeadlessCommandLineContractTests()
|
||||
{
|
||||
// The spec builders refuse to name an executable that is not there, so
|
||||
// the contract can only be exercised against files that exist.
|
||||
Directory.CreateDirectory(AppDirectory);
|
||||
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
File.WriteAllText(Path.Combine(AppDirectory, "AcDream.App" + suffix), "stub");
|
||||
File.WriteAllText(Path.Combine(AppDirectory, "acdream-headless" + suffix), "stub");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeArgumentsParseAsAHeadlessRunCommand()
|
||||
{
|
||||
LauncherProcessSpec spec = ExecutableSet().CreateProbeSpec(ConfigPath);
|
||||
|
||||
HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments);
|
||||
|
||||
Assert.Equal("run", parsed.Command);
|
||||
Assert.Equal(ConfigPath, parsed.ConfigurationPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessPlayArgumentsParseAsAHeadlessRunCommand()
|
||||
{
|
||||
LauncherProcessSpec spec = ExecutableSet()
|
||||
.CreatePlaySpec(LaunchMode.Headless, ConfigPath);
|
||||
|
||||
HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments);
|
||||
|
||||
Assert.Equal("run", parsed.Command);
|
||||
Assert.Equal(ConfigPath, parsed.ConfigurationPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The graphical host is a DIFFERENT program with a different grammar — it
|
||||
/// takes a bare <c>--session-config</c> and has no command word. Copying
|
||||
/// that shape onto the headless call is exactly the mistake this file
|
||||
/// exists to prevent, so pin the difference rather than leaving it implied.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(LaunchMode.Gui)]
|
||||
[InlineData(LaunchMode.GuiSelect)]
|
||||
public void GraphicalArgumentsAreNotAHeadlessCommandLine(LaunchMode mode)
|
||||
{
|
||||
LauncherProcessSpec spec = ExecutableSet().CreatePlaySpec(mode, ConfigPath);
|
||||
|
||||
Assert.Equal(["--session-config", ConfigPath], spec.Arguments);
|
||||
Assert.Throws<HeadlessCommandLineException>(
|
||||
() => HeadlessCommandLine.Parse(spec.Arguments));
|
||||
}
|
||||
|
||||
private string AppDirectory => Path.Combine(_root, "app");
|
||||
|
||||
private string ConfigPath => Path.Combine(_root, "session.json");
|
||||
|
||||
private LauncherExecutableSet ExecutableSet() =>
|
||||
LauncherExecutableSet.FromDirectory(AppDirectory);
|
||||
}
|
||||
|
|
@ -221,7 +221,10 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
Assert.True(viewModel.ShowLinuxGraphicalNotice);
|
||||
Assert.False(viewModel.CanLaunchGui);
|
||||
Assert.False(viewModel.CanLaunchGuiSelect);
|
||||
// LU7: a selected character offers only Play and Headless. Character
|
||||
// select is an account-level action and no longer has a per-character
|
||||
// command to gate.
|
||||
Assert.False(viewModel.CanLaunchAccountGuiSelect);
|
||||
Assert.True(viewModel.CanLaunchHeadless);
|
||||
Assert.Equal(
|
||||
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue