diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 3a298710..727c340e 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -1023,30 +1023,44 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory Keyboard: new KeyboardRuntimeBindings( d.InputDispatcher, d.KeyBindingsFilePath), - CharacterSelection: d.Options.LiveCharacterSelector is null - ? new CharacterSelectionRuntimeBindings( - () => late.GameRuntime.CharacterSelection, - late.GameRuntime.CharacterSelectionHighlight, - late.GameRuntime.CharacterSelectionEnter, - late.GameRuntime.CharacterSelectionRequestDelete, - late.GameRuntime.CharacterSelectionConfirmDelete, - late.GameRuntime.CharacterSelectionRestore, - late.GameRuntime.CharacterSelectionCancel, - // Campaign LA gate round 2 finding 1: the SAME - // window-close path GameplayInputCommandController's - // Escape fallback uses (IGameplayWindowCommands.Close - // /GameplayWindowCommands wrap this same d.Window.Close - // delegate) — no separate exit path. - d.Window.Close) - : null, + // LU10: built UNCONDITIONALLY, including when the launcher + // supplied a character selector. The selector only decides how + // this session STARTS — straight into the world instead of + // pausing at the select screen. It must not decide whether the + // select screen EXISTS, because the player can come back to it: + // the toolbar's X (IndicatorBarController's + // EndCharacterSessionButtonId 0x100000FA) runs retail's + // EndCharacterSession, and LiveSessionController's logout + // transaction ends by resetting the world generation and + // calling CharacterSelectionState.Begin — i.e. it hands control + // to exactly these bindings. Gating them on the selector meant a + // launcher-started session logged out into a client with + // nowhere to land. + CharacterSelection: new CharacterSelectionRuntimeBindings( + () => late.GameRuntime.CharacterSelection, + late.GameRuntime.CharacterSelectionHighlight, + late.GameRuntime.CharacterSelectionEnter, + late.GameRuntime.CharacterSelectionRequestDelete, + late.GameRuntime.CharacterSelectionConfirmDelete, + late.GameRuntime.CharacterSelectionRestore, + late.GameRuntime.CharacterSelectionCancel, + // Campaign LA gate round 2 finding 1: the SAME + // window-close path GameplayInputCommandController's + // Escape fallback uses (IGameplayWindowCommands.Close + // /GameplayWindowCommands wrap this same d.Window.Close + // delegate) — no separate exit path. + d.Window.Close), // Campaign CC slice CC4: same late-bound generation-capturing // seam as CharacterSelection above. RequestExit here is a // plain presentation action (closing the chargen screen and // letting character-management's own Tick keep re-drawing // itself underneath — see CharacterCreationUiController's // OnExit doc), NOT a Runtime command or a window-close. - CharacterCreation: d.Options.LiveCharacterSelector is null - ? new CharacterCreationRuntimeBindings( + // LU10: unconditional for the same reason as CharacterSelection + // above — a player who logs out back to the select screen can + // create a character from there, so the screen behind it must + // exist regardless of how this session started. + CharacterCreation: new CharacterCreationRuntimeBindings( () => late.GameRuntime.CharacterCreation, late.GameRuntime.CharacterCreationSelectHeritage, late.GameRuntime.CharacterCreationSelectGender, @@ -1076,8 +1090,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, GetSkillScore: chargenSkillScoreResolver.Resolve, - OpenOnStart: d.Options.OpenCharacterCreationOnStart) - : null); + OpenOnStart: d.Options.OpenCharacterCreationOnStart)); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index f7923808..d52acae7 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -15,6 +15,11 @@ namespace AcDream.Launcher.Core.Orchestration; /// public sealed class LauncherOrchestrator : ILauncherOrchestrator { + /// How long the server may keep an account logged in after a + /// client dies without sending a logout. Observed against ACE at three + /// minutes and more; see GetReconnectHoldLocked. + private static readonly TimeSpan ServerSessionHold = TimeSpan.FromMinutes(3); + private const string FirstRunRequired = "Client content is not configured. Complete the first-run setup before launching."; @@ -142,14 +147,62 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ThrowIfDisposed(); _ = FindAccountLocked(serverName, accountName); ManagedActivity? active = FindActiveActivityLocked(serverName, accountName); - return active is null - ? LauncherCapability.Available - : LauncherCapability.Unavailable( + if (active is not null) + { + return LauncherCapability.Unavailable( $"Stop the running {active.Kind.ToString().ToLowerInvariant()} " + "for this account before starting another activity."); + } + + return GetReconnectHoldLocked(serverName, accountName); } } + /// + /// LU9. A client that shuts itself down sends the server a logout and the + /// account frees in a few seconds. One that is killed or crashes sends + /// nothing, and the server keeps the account logged in until its own + /// timeout — observed at three minutes and more against ACE. Logging in + /// during that window does not queue or retry; it fails with a bare + /// "CharacterList not received", which reads as "the launcher is broken" + /// rather than "the server is still holding your last session". + /// + /// So the launcher holds the account itself for that window and says + /// how long is left. This is the server's constraint made visible, not a + /// retry or a grace period hiding one: the moment the hold expires the + /// account is offered again, and a graceful exit never starts one. + /// + private LauncherCapability GetReconnectHoldLocked( + string serverName, + string accountName) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + ManagedActivity? ungraceful = _activities + .Where(activity => + activity.IsTerminal + && !activity.ExitedGracefully + && activity.TerminalAt is not null + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal) + && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal)) + .OrderByDescending(activity => activity.TerminalAt) + .FirstOrDefault(); + if (ungraceful?.TerminalAt is not { } terminalAt) + { + return LauncherCapability.Available; + } + + TimeSpan remaining = ServerSessionHold - (now - terminalAt); + if (remaining <= TimeSpan.Zero) + { + return LauncherCapability.Available; + } + + int seconds = (int)Math.Ceiling(remaining.TotalSeconds); + return LauncherCapability.Unavailable( + $"The last session for this account did not log out cleanly, so the " + + $"server may still be holding it. Try again in {seconds} s."); + } + public LauncherCapability GetProbeCapability(string serverName, string accountName) { ArgumentException.ThrowIfNullOrWhiteSpace(serverName); @@ -838,6 +891,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { case ExitedStatusEvent exited: activity.ExitCode ??= exited.Code; + activity.HostReportedExit = true; + activity.ExitReason ??= exited.Reason; activity.HostTerminalStatus ??= $"Exited: {exited.Reason} (code {exited.Code})."; if (activity.State == LauncherActivityState.Exited) @@ -904,6 +959,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator case ExitedStatusEvent exited: activity.State = LauncherActivityState.Exited; activity.ExitCode = exited.Code; + activity.HostReportedExit = true; + activity.ExitReason = exited.Reason; activity.HostTerminalStatus = $"Exited: {exited.Reason} (code {exited.Code})."; activity.Status = activity.HostTerminalStatus; @@ -1289,7 +1346,26 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public DateTimeOffset CreatedAt { get; } - public LauncherActivityState State { get; set; } = LauncherActivityState.Starting; + private LauncherActivityState _state = LauncherActivityState.Starting; + + /// + /// LU9: stamps on the FIRST transition into a + /// terminal state. Every terminal path in this file assigns through + /// here, so the server-side hold cannot be started from one site and + /// forgotten at another. + /// + public LauncherActivityState State + { + get => _state; + set + { + _state = value; + if (IsTerminal) + { + TerminalAt ??= DateTimeOffset.UtcNow; + } + } + } public string Status { get; set; } @@ -1299,6 +1375,21 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public string? HostTerminalStatus { get; set; } + /// LU9: the host's own terminal reason token, set only when + /// the host actually reported "exited" — so null means it never ran + /// its own teardown. + public string? ExitReason { get; set; } + + /// LU9: the host reported its own terminal event, as opposed + /// to the launcher merely observing the process disappear. + public bool HostReportedExit { get; set; } + + /// LU9: when this activity first became terminal, which is + /// when the server-side hold starts counting. + public DateTimeOffset? TerminalAt { get; set; } + + public bool ExitedGracefully => HostReportedExit && ExitCode == 0; + public ILauncherProcessSupervisor? Supervisor { get; set; } public EventHandler? SupervisorStateHandler { get; set; } @@ -1332,7 +1423,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator Status, ExitCode, Error, - CreatedAt); + CreatedAt, + ExitReason, + // Graceful means the HOST ran its own shutdown and exited + // clean, which is exactly the path that sends the server a + // logout. A killed or crashed child never reports "exited" + // and never exits zero, so it cannot be mistaken for one. + ExitedGracefully); } private sealed class StartRequest( diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs index 918e1065..e71d0847 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs @@ -61,7 +61,17 @@ public sealed record LauncherSessionSnapshot( string Status, int? ExitCode, string? Error, - DateTimeOffset CreatedAt) + DateTimeOffset CreatedAt, + /// LU9: the host's own machine-readable terminal reason + /// ("process-exit", "connection-error", "probe", ...), or null when the + /// host never got to report one — which itself means it did not shut + /// itself down. + string? ExitReason = null, + /// LU9: the host ran its OWN teardown and exited zero, so it + /// sent the server a logout. When this is false after a terminal state, + /// the server may still be holding the session — see + /// 's reconnect hold. + bool ExitedGracefully = false) { public bool IsActive => State is not ( LauncherActivityState.Exited diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index da056eca..7cdb3031 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -227,14 +227,27 @@ - +