From 0a7dc7d626e6f3592ea6d67946bee30e83530ec6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:02:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(runtime,ui):=20Campaign=20LA=20gate=20round?= =?UTF-8?q?=202=20=E2=80=94=20world=20name=20reads=20durably;=20dialogs=20?= =?UTF-8?q?center=20on=20the=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-integration gaps the ef96c554 unit tests could not see: 1. World box stayed empty against ACE: ServerName (0xF7E1) arrives in the SAME connect batch as CharacterList, so ServerNameReceived fires during the handshake pump BEFORE the controller binding subscribes - the event-only wiring proved the state and controller but never the live ordering. StartCore now reads the durable WorldSession.ServerInfo after connect exactly like the roster (ILiveSessionOperations.GetServerInfo, default interface method so no fake breaks); the event remains for post-connect updates. Pinned by a Start-level test. 2. The exit confirmation rendered far right of the screen: all three retail dialog views centered against the raw window size while the active screen lays out in the fixed 800x600 canvas - center-of-1920 is canvas-760, which the stretch pushes off-center. Views now center against UiRoot.EffectiveCanvasSize (canvas while a pre-world screen is active, window otherwise). Pinned by growing the window over the fixed canvas in the exit-dialog test and asserting the scrim spans the canvas with the popup centered at 400. Runtime 1666, App 5100+6 skips, green. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/RetailConfirmationDialogView.cs | 9 +++-- .../RetailConfirmationTextInputDialogView.cs | 7 ++-- .../UI/Layout/RetailMessageDialogView.cs | 7 ++-- src/AcDream.App/UI/UiRoot.cs | 13 +++++++ .../Session/LiveSessionController.cs | 18 ++++++++++ .../CharacterManagementUiControllerTests.cs | 15 ++++++++ .../Session/LiveSessionControllerTests.cs | 34 +++++++++++++++++++ 7 files changed, 97 insertions(+), 6 deletions(-) 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() {