feat(launcher): LU9/LU10 — stop logs out for real, sessions read plainly, logout lands on character select
Some checks failed
CI / linux-portable (push) Failing after 2m23s
CI / windows-gate (push) Successful in 5m30s
CI / release (push) Has been skipped

Five things from the user's gate.

STOP NOW ACTUALLY LOGS OUT. The UI gave the client five seconds and then
killed it. That is not enough for a graphical client to send its logout, wait
for the server to acknowledge, and tear down a mapped 28 GB world — so Stop
routinely ended in a kill, which sends the server nothing, which is exactly
what leaves the account held. Thirty seconds now, with the kill still there as
a genuine last resort, and the status says "Logging out…".

THE SERVER-SIDE HOLD IS MODELLED INSTEAD OF DISCOVERED. A session that ends
without the host running its own teardown may leave the account logged in
server-side for minutes. Launching again inside that window does not queue or
retry — it fails with a bare "CharacterList not received", which reads as a
broken launcher rather than a busy server. The orchestrator now records whether
each session ended gracefully (the host reported its own exit AND exited zero —
a killed or crashed child can satisfy neither) and refuses that account for
three minutes afterwards, saying how many seconds are left. A graceful exit
never starts a hold.

READABLE TERMINAL TEXT. "Exited: connection-error (code 5)" becomes "Could not
reach the server — the server may hold this account for a few minutes";
"Exited: process-exit (code 0)" becomes "Exited gracefully — logged out
cleanly". Live sessions still show the host's own status line, which is the
most informative thing available while one is running.

COLUMN HEADERS on the sessions list — ACCOUNT / CHARACTER / STATUS / DETAIL,
sharing the row template's widths so they stay aligned.

LOGOUT LANDS ON THE CHARACTER SCREEN (LU10). The toolbar X was already wired
correctly: IndicatorBarController's EndCharacterSessionButtonId 0x100000FA runs
retail's EndCharacterSession, and LiveSessionController's logout transaction
already ends by resetting the world generation and calling
CharacterSelectionState.Begin. What was missing is where that lands: the
retained UI built its character-selection and character-creation bindings only
when NO character selector was supplied, so a launcher-started session logged
out into a client with no screen to return to. The selector decides how a
session STARTS; it must not decide whether the select screen EXISTS. Both
binding sets are now unconditional.

The composition test that pinned the old gate is updated to pin the new
contract — the retained UI must not branch on the selector at all — rather than
being deleted.

App 5380 passed, Launcher.Core 336, Launcher 76, Headless 169. Not pushed; the
user is testing locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 21:27:26 +02:00
parent 18bbd37779
commit 6ab5d8ce0f
7 changed files with 234 additions and 32 deletions

View file

@ -183,11 +183,19 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType == typeof(CharacterList)
&& call.Target.Name == nameof(CharacterList.TrySelectFirstAvailable));
// LU10: the retained UI must NOT branch on the selector. The selector
// decides how a session STARTS (above — straight into the world instead
// of pausing at the select screen); it must not decide whether the
// select screen EXISTS. Retail's EndCharacterSession — the toolbar X —
// ends by resetting the world generation and calling
// CharacterSelectionState.Begin, so a launcher-started session that
// logs out lands on exactly these bindings. While they were gated on
// the selector, it landed on nothing.
MethodInfo retainedUi = typeof(RetailInteractionRetainedUiCompositionFactory)
.GetMethod(nameof(
RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!;
IReadOnlyList<CompiledCall> uiCalls = CompiledCallGraph.Read(retainedUi);
Assert.Contains(
Assert.DoesNotContain(
uiCalls,
call => call.Target.DeclaringType == typeof(RuntimeOptions)
&& call.Target.Name == "get_LiveCharacterSelector");
@ -196,6 +204,11 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType
== typeof(CharacterSelectionRuntimeBindings)
&& call.Target.IsConstructor);
Assert.Contains(
uiCalls,
call => call.Target.DeclaringType
== typeof(AcDream.App.UI.Layout.CharacterCreationRuntimeBindings)
&& call.Target.IsConstructor);
}
private sealed class RetryBinding(