feat(session): the in-world logoff — LogOut animation, reverse wormhole, live return to character select
Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and files AD-110 (the composed handoff edge) — register rows in this commit. Retail derivation (named decomp): - gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into CPlayerSystem::LogOffCharacter(0) when grounded (transient_state & CONTACT); the grounded three-way branch now also covers the indicator-bar end-session control (it was Options-only). - CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the existing pre-logoff flush hook), then RequestLogOff @0x00562DD0: 'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter @0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and CommandInterpreter::HandleLogOff @0x006B3330 -> Disable. - The log-off ANIMATION is server-driven: ACE broadcasts MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands) and it plays on the local player through the existing inbound unpack_movement funnel during the 3 s hold — retail plays nothing locally; Disable() is the whole client-side effect. - gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed -> BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue @0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel plays the SAME forward 40 fps animation; nothing renders backwards, and NO exit cue ever fires on logout (the char-select swap preempts the TunnelContinue/FadeOut tail). - Inbound 0xF653 echo (dispatch case 3 @0x0055C963) -> ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION KEPT (ExitWorldDisconnect @0x00541E00 removes every connection except logonRecID_ — one connection against ACE) and Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in the same batch re-shows character management (gmGamePlayUI::Update @0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it: SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList + ServerName >=6 s after the request and leaves the session AuthConnected — a second EnterWorld needs no re-handshake. Implementation: - RuntimeWorldTransitState: the canonical logout lifecycle (Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds, cancel/reset/ownership convergence). - WorldSession: RequestCharacterLogOff (non-blocking 0xF653), IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld -> InCharacterSelect + game-action sequence reset; transport untouched). - LiveSessionController: BeginCharacterLogOff (flush-first request) and CompleteCharacterLogOff — the return-to-selection transaction (ReconnectCore minus the transport swap: retire the world generation's routes, host reset, state flip, fresh generation re-bind, roster re-applied from the pushed CharacterList; failures degrade to the full StopCore teardown). - RuntimeLocalPlayerMovementState.DisableCommandInterpreter + DispatcherMovementInputSource gate: retail's Disable() — held keys produce no movement while the server LogOut motion plays; cleared by the generation reset. - LocalPlayerTeleportController: the logout pump as the third arm of the one wormhole machine (request/hold/wormhole/confirmed handoff; teleport starts refused during logout; the handoff runs the session transaction whose world reset retires the tunnel as the fresh selection state re-shows the character screen). - UI: both end-session surfaces share the retail three-way grounded gate and now run the REAL flow; Options' Exit Game keeps the app exit (window close -> the existing graceful-shutdown logoff). Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump. Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3 + 11 this round), Core.Net 1004/0, full solution green (0 failures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2bc81480d4
commit
d233f81dce
17 changed files with 1399 additions and 37 deletions
|
|
@ -238,6 +238,8 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
HostQuiescence: null!,
|
||||
RetainedInputCapture: null!,
|
||||
InputDispatcher: null,
|
||||
TeleportSink:
|
||||
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
|
||||
KeyBindingsFilePath: "keybinds.json",
|
||||
Settings: null!,
|
||||
Runtime: runtime,
|
||||
|
|
|
|||
|
|
@ -1829,6 +1829,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -1063,6 +1063,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
|||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -935,6 +935,13 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
public readonly FakeLoginLifecycleSource LoginLifecycle = new();
|
||||
|
||||
/// <summary>
|
||||
/// Logout round (2026-08-17): the logout pump's Runtime seams —
|
||||
/// mutable wire/confirmation/transaction outcomes so tests drive the
|
||||
/// full request → hold → wormhole → confirmation → handoff flow.
|
||||
/// </summary>
|
||||
public readonly FakeLogoutOperations Logout = new();
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
int centerY = 0x21,
|
||||
|
|
@ -1064,7 +1071,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Session,
|
||||
Presentation,
|
||||
AcceptedPositionDrive,
|
||||
LoginLifecycle);
|
||||
LoginLifecycle,
|
||||
Logout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1483,6 +1491,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
}
|
||||
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
||||
public void ArmLoginTunnel() => LoginTunnelArms++;
|
||||
public int LogoutRequests;
|
||||
public void RequestLogout() => LogoutRequests++;
|
||||
public void ResetSession()
|
||||
{
|
||||
}
|
||||
|
|
@ -1871,6 +1881,164 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
}
|
||||
|
||||
// ── Logout round (2026-08-17): the logout arm of the wormhole machine.
|
||||
//
|
||||
// Retail: CPlayerSystem::RequestLogOff @ 0x00562DD0 (chat + 0xF653 +
|
||||
// 3 s hold (+20 PK) + interpreter Disable) →
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E64 hold-elapsed →
|
||||
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83 (enter cue,
|
||||
// @ 0x004D638E) → TunnelFadeIn → Tunnel hold → inbound 0xF653 echo →
|
||||
// ExecuteLogOff @ 0x0055D780 + CharacterList-driven char-select swap.
|
||||
// No exit cue on logout (the swap preempts the tail) — AD-110.
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_SendsWireThenHoldsThreeSeconds_ThenBeginsWormhole()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: true, order: order);
|
||||
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
Assert.Equal(1, harness.Logout.BeginCalls);
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
|
||||
Assert.Equal(1, harness.Input.EndCount);
|
||||
|
||||
// The 3 s hold: no presentation, the server-broadcast LogOut motion
|
||||
// is playing in-world.
|
||||
harness.Controller.Tick(1.0f);
|
||||
harness.Controller.Tick(1.0f);
|
||||
Assert.DoesNotContain("presentation-begin-logout", order);
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
|
||||
|
||||
// Hold elapses → the wormhole begins at WorldFadeOut with the enter
|
||||
// cue (BeginTeleportAnimation plays it unconditionally).
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
|
||||
harness.Controller.Tick(1.05f);
|
||||
Assert.Contains("presentation-begin-logout", order);
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
harness.Transit.LogoutStage);
|
||||
Assert.Equal(["enter"], harness.Presentation.Cues);
|
||||
|
||||
// The tunnel edge arrives on its own sequencer event.
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.All(
|
||||
harness.Presentation.WorldReadyValues,
|
||||
value => Assert.False(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmation_RunsTheHandoffOnceAndCompletesTheLifecycle()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: true, order: order);
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.Tick(3.05f);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
|
||||
// The server's opcode-only 0xF653 echo lands (ACE sends it >= 6 s
|
||||
// after the request, tunnel always up by then): the next tick
|
||||
// acknowledges and runs the handoff IMMEDIATELY — retail's
|
||||
// ExecuteLogOff-on-echo (AD-110's composed edge).
|
||||
harness.Logout.IsCharacterLogOffConfirmed = true;
|
||||
harness.Logout.OnComplete = () =>
|
||||
harness.Controller.ResetGenerationPresentation();
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
|
||||
// The transaction's world reset retired the presentation.
|
||||
Assert.Contains("presentation-reset", order);
|
||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
||||
// No exit cue on logout — the swap preempts the tail.
|
||||
Assert.Equal(["enter"], harness.Presentation.Cues);
|
||||
|
||||
// The retired lifecycle stays quiet.
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely()
|
||||
{
|
||||
// Retail's ExecuteLogOff clears logOffRequested — a confirmation
|
||||
// landing before the hold elapses cancels the pending wormhole and
|
||||
// the CharacterList swap happens directly (unreachable against
|
||||
// ACE's >= 6 s floor, but the machine is total).
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: true, order: order);
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
|
||||
harness.Logout.IsCharacterLogOffConfirmed = true;
|
||||
harness.Logout.OnComplete = () =>
|
||||
harness.Controller.ResetGenerationPresentation();
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Logout.CompleteCalls);
|
||||
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
|
||||
Assert.DoesNotContain("presentation-begin-logout", order);
|
||||
Assert.Empty(harness.Presentation.Cues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_RefusedWireRollsTheLifecycleBack()
|
||||
{
|
||||
var harness = new Harness(worldReady: true);
|
||||
harness.Logout.BeginResult = false;
|
||||
|
||||
Assert.False(harness.Controller.TryRequestLogout());
|
||||
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
|
||||
Assert.Equal(1, harness.Logout.BeginCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_UsesThePlayerKillerHold()
|
||||
{
|
||||
var harness = new Harness(worldReady: true);
|
||||
harness.Logout.IsLocalPlayerKiller = true;
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
|
||||
// 3 s is NOT enough for a PK — retail adds +20 s
|
||||
// (RequestLogOff @ 0x00562E67).
|
||||
harness.Controller.Tick(3.5f);
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
|
||||
harness.Controller.Tick(19.6f);
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
harness.Transit.LogoutStage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportStart_IsIgnoredWhileLogoutIsActive()
|
||||
{
|
||||
var harness = new Harness(worldReady: true);
|
||||
Assert.True(harness.Controller.TryRequestLogout());
|
||||
|
||||
harness.Controller.OnTeleportStarted(7);
|
||||
Assert.False(harness.Controller.IsActive);
|
||||
Assert.False(harness.Transit.HasPendingTeleportStart);
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_RefusedDuringTeleportOrLogin()
|
||||
{
|
||||
var harness = new Harness(worldReady: true);
|
||||
harness.Controller.OnTeleportStarted(3);
|
||||
Assert.False(harness.Controller.TryRequestLogout());
|
||||
Assert.Equal(0, harness.Logout.BeginCalls);
|
||||
|
||||
var loginHarness = new Harness(worldReady: false);
|
||||
loginHarness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
loginHarness.Controller.ArmLoginTunnel();
|
||||
Assert.False(loginHarness.Controller.TryRequestLogout());
|
||||
Assert.Equal(0, loginHarness.Logout.BeginCalls);
|
||||
}
|
||||
|
||||
private sealed class StubLoginState : IRenderLoginStateSource
|
||||
{
|
||||
public bool IsWaitingForLogin { get; set; }
|
||||
|
|
@ -1886,6 +2054,30 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
||||
}
|
||||
|
||||
private sealed class FakeLogoutOperations : ILocalPlayerLogoutOperations
|
||||
{
|
||||
public bool IsLocalPlayerKiller { get; set; }
|
||||
public bool BeginResult = true;
|
||||
public int BeginCalls;
|
||||
public bool IsCharacterLogOffConfirmed { get; set; }
|
||||
public bool CompleteResult = true;
|
||||
public int CompleteCalls;
|
||||
public Action? OnComplete;
|
||||
|
||||
public bool BeginCharacterLogOff()
|
||||
{
|
||||
BeginCalls++;
|
||||
return BeginResult;
|
||||
}
|
||||
|
||||
public bool CompleteCharacterLogOff()
|
||||
{
|
||||
CompleteCalls++;
|
||||
OnComplete?.Invoke();
|
||||
return CompleteResult;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
|
@ -1910,6 +2102,12 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
_order.Add("presentation-begin");
|
||||
}
|
||||
|
||||
public void BeginLogout(Matrix4x4 projection)
|
||||
{
|
||||
BeginProjection = projection;
|
||||
_order.Add("presentation-begin-logout");
|
||||
}
|
||||
|
||||
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -169,6 +169,30 @@ public sealed class LiveSessionControllerTests
|
|||
}
|
||||
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
|
||||
}
|
||||
|
||||
// Logout round (2026-08-17): the in-world logoff pair — recorded
|
||||
// here so the return-to-selection transaction is testable without a
|
||||
// live in-world WorldSession state machine.
|
||||
public int RequestCharacterLogOffCount { get; private set; }
|
||||
public int ReturnToCharacterSelectCount { get; private set; }
|
||||
public bool ThrowOnRequestCharacterLogOff { get; set; }
|
||||
public bool ThrowOnReturnToCharacterSelect { get; set; }
|
||||
|
||||
public void RequestCharacterLogOff(WorldSession session)
|
||||
{
|
||||
calls.Add("request-character-logoff");
|
||||
RequestCharacterLogOffCount++;
|
||||
if (ThrowOnRequestCharacterLogOff)
|
||||
throw new InvalidOperationException("logoff request failure");
|
||||
}
|
||||
|
||||
public void ReturnToCharacterSelect(WorldSession session)
|
||||
{
|
||||
calls.Add("return-to-character-select");
|
||||
ReturnToCharacterSelectCount++;
|
||||
if (ThrowOnReturnToCharacterSelect)
|
||||
throw new InvalidOperationException("return failure");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
|
||||
|
|
@ -1619,6 +1643,144 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Single(operations.DisposeCounts);
|
||||
}
|
||||
|
||||
// ── Logout round (2026-08-17): the in-world logoff transaction pair —
|
||||
// retail CPlayerSystem::LogOffCharacter(0) @ 0x00563520 (flush-first
|
||||
// 0xF653 request) and ExecuteLogOff @ 0x0055D780 composed with the
|
||||
// CharacterList-driven character-select re-show (AD-110). ─────────────
|
||||
|
||||
[Fact]
|
||||
public void BeginCharacterLogOff_FlushesFirstThenSendsTheRequest()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.ConfigurePreLogoffFlush(_ => calls.Add("flush"));
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Connected,
|
||||
controller.Start(LiveOptions(), host).Status);
|
||||
|
||||
RuntimeCommandResult result =
|
||||
controller.BeginCharacterLogOff(controller.Generation);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Equal(1, operations.RequestCharacterLogOffCount);
|
||||
// SaveToServer BEFORE the wire request — LogOffCharacter @ 0x00563528.
|
||||
Assert.True(
|
||||
calls.IndexOf("flush") < calls.IndexOf("request-character-logoff"));
|
||||
// No teardown of any kind at request time (the single reset on
|
||||
// record is Start's own initial host reset).
|
||||
Assert.True(controller.IsInWorld);
|
||||
Assert.Equal(1, host.ResetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCharacterLogOff_RefusesOutsideTheWorld()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeCommandStatus.Inactive,
|
||||
controller.BeginCharacterLogOff(controller.Generation).Status);
|
||||
Assert.Equal(0, operations.RequestCharacterLogOffCount);
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Connected,
|
||||
controller.Start(LiveOptions(), host).Status);
|
||||
Assert.Equal(
|
||||
RuntimeCommandStatus.StaleGeneration,
|
||||
controller.BeginCharacterLogOff(default).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteCharacterLogOff_ReturnsToSelectionOnTheLiveSession()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Connected,
|
||||
controller.Start(LiveOptions(), host).Status);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
RuntimeGenerationToken worldGeneration = controller.Generation;
|
||||
calls.Clear();
|
||||
|
||||
RuntimeCommandResult result =
|
||||
controller.CompleteCharacterLogOff(worldGeneration);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
// The retiring world generation's routes died first, then the host
|
||||
// reset THAT generation, then the same live session flipped back —
|
||||
// no transport disposal anywhere.
|
||||
Assert.Equal(
|
||||
[
|
||||
"deactivate", "detach-events", "detach-session", "reset",
|
||||
"return-to-character-select", "bind", "roster",
|
||||
],
|
||||
calls);
|
||||
// ResetGenerations[0] is Start's own initial host reset; the
|
||||
// transaction's reset targets exactly the retiring world generation.
|
||||
Assert.Equal(2, host.ResetCount);
|
||||
Assert.Equal(worldGeneration, host.ResetGenerations[^1]);
|
||||
Assert.Empty(operations.DisposeCounts);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Same(session, controller.CurrentSession);
|
||||
Assert.NotEqual(worldGeneration, controller.Generation);
|
||||
Assert.Equal(result.Generation, controller.Generation);
|
||||
|
||||
// The fresh generation owns an AwaitingSelection roster re-applied
|
||||
// from the session cache ACE's post-logoff CharacterList filled.
|
||||
RuntimeCharacterSelectionSnapshot snapshot =
|
||||
controller.CharacterSelectionState.View.Snapshot;
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||||
snapshot.Lifecycle);
|
||||
Assert.Equal(controller.Generation, snapshot.Generation);
|
||||
Assert.Equal(2, host.Rosters.Count);
|
||||
|
||||
// The round trip: a second Enter works on the SAME session.
|
||||
RuntimeCommandResult enter = controller.Enter(controller.Generation);
|
||||
Assert.True(enter.Accepted);
|
||||
Assert.True(controller.IsInWorld);
|
||||
Assert.Equal(2, operations.EnterWorldCount);
|
||||
Assert.True(host.CommandBuses[^1].Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteCharacterLogOff_RefusalsAndFailureDegradeToStop()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeCommandStatus.Inactive,
|
||||
controller.CompleteCharacterLogOff(controller.Generation).Status);
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Connected,
|
||||
controller.Start(LiveOptions(), host).Status);
|
||||
Assert.Equal(
|
||||
RuntimeCommandStatus.StaleGeneration,
|
||||
controller.CompleteCharacterLogOff(default).Status);
|
||||
|
||||
// A mid-transaction failure must not leave a half-reset session:
|
||||
// the transaction degrades to the full StopCore teardown.
|
||||
operations.ThrowOnReturnToCharacterSelect = true;
|
||||
WorldSession session = operations.Sessions[0];
|
||||
RuntimeCommandResult result =
|
||||
controller.CompleteCharacterLogOff(controller.Generation);
|
||||
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[session]);
|
||||
}
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions(
|
||||
bool live = true,
|
||||
string? user = "user",
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,104 @@ public sealed class RuntimeWorldTransitStateTests
|
|||
state.ResetSession();
|
||||
}
|
||||
|
||||
// ── Logout round (2026-08-17): the character-logoff lifecycle —
|
||||
// retail CPlayerSystem::RequestLogOff @ 0x00562DD0 (3 s hold, +20 PK),
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E64 (hold-elapsed presentation begin),
|
||||
// ExecuteLogOff @ 0x0055D780 (the 0xF653 echo), and the char-select
|
||||
// handoff. ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LogoutLifecycle_RequestHoldPresentationConfirmComplete()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
|
||||
Assert.Equal(1, state.CaptureOwnership().ActiveLogoutCount);
|
||||
Assert.False(state.CaptureOwnership().IsSessionIdle);
|
||||
|
||||
// A second request while one is in flight refuses.
|
||||
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
|
||||
// The retail 3.0 s hold: not elapsed at 2.9, elapsed at 3.0+.
|
||||
Assert.False(state.AdvanceLogoutHold(2.9d));
|
||||
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
|
||||
Assert.True(state.AdvanceLogoutHold(0.2d));
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
state.LogoutStage);
|
||||
// The begin edge fires exactly once.
|
||||
Assert.False(state.AdvanceLogoutHold(1.0d));
|
||||
|
||||
Assert.True(state.AcknowledgeLogoutConfirmed());
|
||||
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
|
||||
Assert.False(state.AcknowledgeLogoutConfirmed());
|
||||
|
||||
Assert.True(state.CompleteLogout());
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
Assert.False(state.CompleteLogout());
|
||||
Assert.True(state.CaptureOwnership().IsSessionIdle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutHold_PlayerKillerAddsTwentySeconds()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: true));
|
||||
|
||||
// 3 s is not enough for a PK (RequestLogOff @ 0x00562E67: +20.0).
|
||||
Assert.False(state.AdvanceLogoutHold(3.5d));
|
||||
Assert.False(state.AdvanceLogoutHold(19.0d));
|
||||
Assert.True(state.AdvanceLogoutHold(0.6d));
|
||||
Assert.Equal(
|
||||
RuntimeLogoutStage.PresentationActive,
|
||||
state.LogoutStage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutConfirmation_LegalFromTheRequestHold()
|
||||
{
|
||||
// ACE's >= 6 s confirmation floor makes this unreachable live, but
|
||||
// the machine is total: a confirmation during the hold cancels the
|
||||
// pending wormhole (retail ExecuteLogOff clears logOffRequested).
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.True(state.AcknowledgeLogoutConfirmed());
|
||||
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
|
||||
// The hold no longer advances a confirmed lifecycle.
|
||||
Assert.False(state.AdvanceLogoutHold(10.0d));
|
||||
Assert.True(state.CompleteLogout());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutRequest_RefusedDuringTeleportAndCancelRollsBack()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
_ = BeginPortal(state, OutdoorCell, sequence: 3);
|
||||
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
|
||||
var idle = new RuntimeWorldTransitState();
|
||||
Assert.True(idle.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
Assert.True(idle.CancelLogoutRequest());
|
||||
Assert.Equal(RuntimeLogoutStage.None, idle.LogoutStage);
|
||||
Assert.True(idle.CaptureOwnership().IsSessionIdle);
|
||||
// Cancel is only legal from Requested.
|
||||
Assert.False(idle.CancelLogoutRequest());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogoutLifecycle_ClearsOnSessionReset()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
state.ResetSession();
|
||||
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
|
||||
Assert.True(state.CaptureOwnership().IsSessionIdle);
|
||||
// A fresh session can log out again.
|
||||
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
|
||||
}
|
||||
|
||||
private static long BeginPortal(
|
||||
RuntimeWorldTransitState state,
|
||||
uint cell,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue