feat(ui): AD-109 — arm the login wormhole at the char-select Enter click
USER-DIRECTED deviation from retail (register row AD-109, same commit): retail presents the empty pre-player gameplay screen — black behind the retained UI — from the Enter click (CPlayerSystem::LogOnCharacter @0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode 0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress @0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The user prefers the tunnel to cover that whole wait. - ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login wormhole presentation at the Enter click, consuming the sequencer's begin-edge events SYNCHRONOUSLY (the Enter command blocks the update thread for the whole ServerReady round trip, so a deferred first tick would leave exactly the black window this deviation removes). The enter cue plays at the click: retail's own rule is cue-at-animation- begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside BeginTeleportAnimation), and the animation begin moved to the click. - Armed pre-reveal pump: tunnel animates across the round trip (worldReady pinned false, sequencer holds in Tunnel); the hold clock accumulates from the click. - Adoption: the Runtime login reveal ADOPTS the running presentation (no re-Begin, no second cue); rejected EnterWorld (lifecycle back to AwaitingSelection) disarms and retires the tunnel. - Wired at the ONE host edge every entry route shares: ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect, roster Enter, enter-after-create) via LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps headless and every existing construction site unchanged). - ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate — the frame-phase owner delegate-field guard) projecting the Runtime character-selection lifecycle for the disarm edge. - Frame contract update: [login-frames] over a login is tunnel -> world from the click — no void, and no black between click and world. Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fdc4fd496d
commit
2bc81480d4
10 changed files with 437 additions and 13 deletions
|
|
@ -1827,6 +1827,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
|||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -920,6 +920,21 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
/// </summary>
|
||||
public bool WorldReady;
|
||||
|
||||
/// <summary>
|
||||
/// Enter-click round (2026-08-17): the Runtime character-selection
|
||||
/// lifecycle the click-armed tunnel projects, resolved per call.
|
||||
/// Defaults to EnteringWorld (an enter transaction in flight — the
|
||||
/// state at every real arm site); armed-tunnel tests regress it to
|
||||
/// AwaitingSelection to drive the disarm.
|
||||
/// </summary>
|
||||
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
|
||||
{
|
||||
get => LoginLifecycle.SelectionLifecycle;
|
||||
set => LoginLifecycle.SelectionLifecycle = value;
|
||||
}
|
||||
|
||||
public readonly FakeLoginLifecycleSource LoginLifecycle = new();
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
int centerY = 0x21,
|
||||
|
|
@ -1048,7 +1063,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Placement,
|
||||
Session,
|
||||
Presentation,
|
||||
AcceptedPositionDrive);
|
||||
AcceptedPositionDrive,
|
||||
LoginLifecycle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1458,6 +1474,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
public List<uint> Starts { get; } = [];
|
||||
public int FirstEntryCompletions;
|
||||
public int LoginTunnelArms;
|
||||
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
||||
public void OfferDestination(
|
||||
RuntimeTeleportDestination destination,
|
||||
|
|
@ -1465,6 +1482,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
}
|
||||
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
||||
public void ArmLoginTunnel() => LoginTunnelArms++;
|
||||
public void ResetSession()
|
||||
{
|
||||
}
|
||||
|
|
@ -1718,11 +1736,156 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.False(source.IsPortalViewportVisible);
|
||||
}
|
||||
|
||||
// ── Enter-click round (2026-08-17): the click-armed login tunnel ────
|
||||
//
|
||||
// Registered deviation AD-109 (user-directed): retail shows BLACK from
|
||||
// the char-select Enter click until CreatePlayer begins TAS_TUNNEL
|
||||
// (gmSmartBoxUI::UseTime @ 0x004D6EAB); acdream arms the same wormhole
|
||||
// presentation AT the click (ApplySelectedCharacter host edge — before
|
||||
// the EnterWorld round trip) and the reveal later ADOPTS it.
|
||||
|
||||
[Fact]
|
||||
public void ArmLoginTunnel_ShowsTunnelAndPlaysCueSynchronously()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: false, order: order);
|
||||
|
||||
// The production sequencer queues PlayEnterSound + EnterTunnel at
|
||||
// Begin; the arm consumes them in the SAME call (the Enter command
|
||||
// blocks the update thread for the whole round trip afterwards).
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
|
||||
Assert.Contains("presentation-begin", order);
|
||||
Assert.Equal(["enter"], harness.Presentation.Cues);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
|
||||
// Pre-reveal ticks keep the tunnel animating (worldReady pinned
|
||||
// false — the sequencer holds in Tunnel) and never re-fire the cue.
|
||||
harness.Controller.Tick(0.016f);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(["enter"], harness.Presentation.Cues);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.All(
|
||||
harness.Presentation.WorldReadyValues,
|
||||
value => Assert.False(value));
|
||||
Assert.Contains("tunnel-tick", order);
|
||||
|
||||
// Idempotent: a second arm (double-click, re-entrant host edge)
|
||||
// never restarts the presentation.
|
||||
int begins = order.Count(entry => entry == "presentation-begin");
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
Assert.Equal(begins, order.Count(entry => entry == "presentation-begin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArmedLoginTunnel_IsAdoptedByTheRevealWithoutRestartOrSecondCue()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: false, order: order);
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
int beginsAtArm = order.Count(entry => entry == "presentation-begin");
|
||||
|
||||
// CreatePlayer's first accepted position begins the Runtime login
|
||||
// reveal; the next tick ADOPTS the running presentation.
|
||||
harness.Reveal.BeginLogin(0x20210001u);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
||||
Assert.Equal(
|
||||
beginsAtArm,
|
||||
order.Count(entry => entry == "presentation-begin"));
|
||||
Assert.Equal(["enter"], harness.Presentation.Cues);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
||||
|
||||
// The adopted presentation completes exactly like the reveal-armed
|
||||
// one: hold ends, viewport swap, one LoginComplete.
|
||||
harness.WorldReady = true;
|
||||
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.True(harness.Presentation.WorldReadyValues[^1]);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
harness.Controller.Tick(0.016f);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
||||
Assert.True(harness.Reveal.Snapshot.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArmedLoginTunnel_DisarmsWhenTheEnterFallsBackToSelection()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(worldReady: false, order: order);
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
|
||||
// Rejected EnterWorld: LiveSessionController.EnterHighlightedCore
|
||||
// applies the error and returns the lifecycle to AwaitingSelection.
|
||||
// The armed pump must retire the tunnel — the character-select
|
||||
// screen is in front again and retail shows no tunnel there.
|
||||
harness.SelectionLifecycle =
|
||||
RuntimeCharacterSelectionLifecycle.AwaitingSelection;
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.Contains("presentation-reset", order);
|
||||
|
||||
// A later successful Enter arms a fresh tunnel.
|
||||
harness.SelectionLifecycle =
|
||||
RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.Equal(["enter", "enter"], harness.Presentation.Cues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArmedLoginTunnel_PresentsTunnelNotBlack_FromTheClickFrame()
|
||||
{
|
||||
// AD-109's frame contract: from the Enter click the composed render
|
||||
// source presents the portal-viewport shape AND the tunnel scene is
|
||||
// already visible — no bare-black CreatePlayer window.
|
||||
var harness = new Harness(worldReady: false);
|
||||
var login = new StubLoginState { IsWaitingForLogin = true };
|
||||
var source = new LocalPlayerTeleportRenderStateSource(
|
||||
harness.Controller, login);
|
||||
|
||||
harness.Presentation.Enqueue(
|
||||
TeleportAnimEvent.PlayEnterSound,
|
||||
TeleportAnimEvent.EnterTunnel);
|
||||
harness.Controller.ArmLoginTunnel();
|
||||
Assert.True(source.IsPortalViewportVisible);
|
||||
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
||||
}
|
||||
|
||||
private sealed class StubLoginState : IRenderLoginStateSource
|
||||
{
|
||||
public bool IsWaitingForLogin { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FakeLoginLifecycleSource
|
||||
: ILocalPlayerLoginLifecycleSource
|
||||
{
|
||||
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
||||
}
|
||||
|
||||
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue