diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs index 71cc4673..d21dbddc 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs @@ -129,10 +129,15 @@ internal sealed class RetailConfirmationDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the space the tree lays out in — the fixed authored + // canvas while a pre-world screen is active (gate round 2: centering + // against the raw window width put the exit dialog far right of the + // stretched 800x600 canvas center). + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs index e21d330b..1b1467c8 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs @@ -150,10 +150,13 @@ internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs index a9246ac5..9ec4d987 100644 --- a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs @@ -95,10 +95,13 @@ internal sealed class RetailMessageDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index fe3246b1..7ca428ac 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -44,6 +44,19 @@ public sealed class UiRoot : UiElement /// public Vector2? FixedCanvasSize { get; set; } + /// + /// The coordinate space the retained tree currently lays out in: the fixed + /// authored canvas while one is active, else the window itself. Anything + /// that positions against "the screen" (dialog centering, full-screen + /// scrims) must use THIS — the gate-round-2 exit dialog centered against + /// the 1920px window while the tree lived in the 800px canvas, landing far + /// right of the visible screen center. + /// + public Vector2 EffectiveCanvasSize => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas + ? canvas + : new Vector2(Width, Height); + /// Window→canvas stretch factor; One when no fixed canvas is set. public Vector2 CanvasScale => FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 3cdc3413..5973e22c 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -172,6 +172,16 @@ public interface ILiveSessionOperations WorldSession CreateSession(IPEndPoint endpoint); void Connect(WorldSession session, string user, string password); CharacterList.Parsed? GetCharacters(WorldSession session); + /// + /// Campaign LA gate round 2 (world-name live fix): ACE sends ServerName + /// (0xF7E1) in the SAME connect-response batch as CharacterList, so the + /// ServerNameReceived event fires during the handshake pump — + /// BEFORE the controller's binding subscribes. The durable + /// field is therefore read + /// synchronously after Connect, exactly like + /// reads the durable roster; the event remains for post-connect updates. + /// + ServerName.Parsed? GetServerInfo(WorldSession session) => session.ServerInfo; void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); void EnterWorld(WorldSession session, int activeCharacterIndex); @@ -753,6 +763,14 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } + // World name arrives in the SAME connect batch as CharacterList, + // during the handshake pump — before the binding's + // ServerNameReceived subscription exists — so the durable field is + // read here exactly like the roster above (gate-round-2 live fix: + // the event-only wiring left the World box empty against ACE). + if (_operations.GetServerInfo(session) is { } serverInfo) + CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); + // Campaign LA slice LA2: the probe short-circuit lands here — // only after a real CharacterList was returned and its roster was // reported, before TrySelectCharacter ever runs. A missing diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 678540f6..b75345e0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -386,6 +386,13 @@ public sealed class CharacterManagementUiControllerTests UiButton exit = environment.Button( CharacterManagementUiController.ExitElementId); + // Gate round 2 follow-up: the window is BIGGER than the fixed canvas + // (the live shape — 1920×1080 window over the 800×600 authored + // screen). The exit dialog centered against the raw window width and + // landed far right of the visible canvas center. + environment.Host.Width = 1920f; + environment.Host.Height = 1080f; + exit.OnClick!(); Assert.NotEqual(0u, controller.ConfirmExitDialogContext); @@ -393,6 +400,14 @@ public sealed class CharacterManagementUiControllerTests Assert.Equal( "Are you sure you want to leave?", Message(dialog)); + // The dialog's scrim + popup must live in CANVAS space: scrim spans + // exactly the canvas, popup centers within it. + Assert.Equal(800f, dialog.Root.Width); + Assert.Equal(600f, dialog.Root.Height); + UiElement popup = Assert.Single( + dialog.Root.Children, + static child => child.Visible && child.Width > 0f); + Assert.InRange(popup.Left + popup.Width * 0.5f, 399f, 401f); DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!(); Assert.Equal(0, environment.Runtime.RequestExitCalls); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 02d8bee4..b2d752a6 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -113,6 +113,13 @@ public sealed class LiveSessionControllerTests return Characters; } + /// Gate-round-2 world-name live fix: mirrors the durable + /// post-connect read the production path performs (the wire event + /// fires during the handshake, before any subscriber exists). + public ServerName.Parsed? ServerInfo { get; set; } + + public ServerName.Parsed? GetServerInfo(WorldSession session) => ServerInfo; + public void StartCharacterSelectionReceive(WorldSession session) => calls.Add("start-character-selection-receive"); @@ -349,6 +356,33 @@ public sealed class LiveSessionControllerTests Assert.True(host.CommandBuses[0].Active); } + /// + /// Gate-round-2 live fix: ACE sends ServerName (0xF7E1) in the SAME + /// connect batch as CharacterList, so the event fires during the + /// handshake pump before the binding subscribes — the World box stayed + /// empty against a live server while the event-only unit tests passed. + /// Start must read the durable ServerInfo after connect, exactly like + /// the roster. + /// + [Fact] + public void Start_AppliesWorldNameFromDurableServerInfoAfterConnect() + { + var calls = new List(); + var operations = new TestOperations(calls) + { + ServerInfo = new ServerName.Parsed(1, 128, "sawato"), + }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + "sawato", + controller.CharacterSelectionState.View.Snapshot.WorldName); + } + [Fact] public void Start_ReportsRosterFromCharacterListBeforeSelection() {