using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign CC slice CC4 review-fix round R1 (2026-08-15): /// and /// can be SIMULTANEOUSLY /// active against the SAME — character-creation opens /// on top of character-management, which keeps ticking underneath it. Both /// controllers share , and before this /// fix each wrote it directly — a raw write from either screen was a last- /// writer-wins race with no owner: the F1 fix's own chargen /// Close() nulled the canvas out from under a STILL-ACTIVE /// character-management screen underneath it (the exact AD-98 gate-round-2 /// defect resurfacing one layer up). These tests drive /// / /// across BOTH controllers on one shared host, per the reviewer's required /// sequence. /// public sealed class CharacterScreensFixedCanvasArbiterTests { private static readonly Vector2 AuthoredCanvas = new(800f, 600f); [Fact] public void CanvasStaysSetWhileEitherScreenIsActive_AndNullsOnlyWhenBothRevoke() { using var environment = new TwoControllerHarness(); // char-mgmt alone: declared on its own activation edge (Tick's // `if (!_active)` arm). Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); // chargen opens ON TOP of the still-active char-mgmt screen. environment.Chargen.Controller.Open(); Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); // chargen Exit-confirms and closes -- char-mgmt is STILL ACTIVE, so // the canvas must stay set. This is R1's regression: the pre-fix // Close() nulled UiRoot.FixedCanvasSize unconditionally here, // stripping it from char-management underneath. environment.Chargen.Button(CharacterCreationUiController.ExitElementId) .OnClick!(); environment.Chargen.ConfirmActiveDialog(confirmed: true); Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); Assert.False(environment.Chargen.Controller.Root.Visible); // char-mgmt deactivates (e.g. entering the world) -- now NEITHER // screen declares, so the canvas nulls. environment.Management.Runtime.SetLifecycle( RuntimeCharacterSelectionLifecycle.InWorld); environment.Management.Controller.Tick(); Assert.Null(environment.Host.FixedCanvasSize); } /// The original F1 defect's own covering case: both screens /// revoke together (world entry while chargen was ALSO still open) /// still nulls the canvas -- not just "one revokes while the other /// holds," the scenario above. [Fact] public void CanvasNulls_WhenBothScreensRevokeAtWorldEntry() { using var environment = new TwoControllerHarness(); environment.Chargen.Controller.Open(); Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); environment.Management.Runtime.SetLifecycle( RuntimeCharacterSelectionLifecycle.InWorld); environment.Management.Controller.Tick(); environment.Chargen.Runtime.ProvideView = false; environment.Chargen.Controller.Tick(); Assert.Null(environment.Host.FixedCanvasSize); } // ── Fixture: one shared UiRoot, both controllers ──────────────────── private sealed class TwoControllerHarness : IDisposable { public TwoControllerHarness() { Host = new UiRoot { Width = 800f, Height = 600f }; Management = new ManagementHarness(Host); Chargen = new ChargenHarness(Host); } public UiRoot Host { get; } public ManagementHarness Management { get; } public ChargenHarness Chargen { get; } public void Dispose() { Chargen.Dispose(); Management.Dispose(); } } private sealed class ManagementHarness : IDisposable { private readonly RetailDialogFactory _dialogs; public ManagementHarness(UiRoot host) { ImportedLayout screen = BuildManagementScreen(); Runtime = new ManagementFakeRuntime(); _dialogs = new RetailDialogFactory( host, type => RetailDialogFactoryTests.BuildDialogLayout(type)); Controller = Assert.IsType( CharacterManagementUiController.Bind( host, screen, static (_, _) => BuildRow(), _dialogs, Runtime.Bindings, new CharacterManagementUiController.DialogStrings( name => $"WARNING! {name}", "DELETE", "Please Wait", "Entering World", "Are you sure you want to leave?"))); } public ManagementFakeRuntime Runtime { get; } public CharacterManagementUiController Controller { get; } public void Dispose() { Controller.Dispose(); _dialogs.Dispose(); } private static UiElement BuildRow() => LayoutImporter.Build( new ElementInfo { Id = 0x100003A5u, Type = 1u, Width = 160f, Height = 16f }, _ => (0u, 0, 0), null).Root; private static ImportedLayout BuildManagementScreen() { var root = new ElementInfo { Id = CharacterManagementUiController.RootElementId, Type = 3u, Width = 800f, Height = 600f, }; var list = new ElementInfo { Id = CharacterManagementUiController.ListElementId, Type = 5u, X = 42f, Y = 212f, Width = 160f, Height = 320f, }; list.TemplateList.Add(new UiTemplateListEntry(0x21000004u, 0x100003A5u)); root.Children.Add(list); root.Children.Add(new ElementInfo { Id = CharacterManagementUiController.WorldTextElementId, Type = 12u, Width = 193f, Height = 110f, }); root.Children.Add(ButtonInfo(CharacterManagementUiController.CreateElementId)); root.Children.Add(ButtonInfo(CharacterManagementUiController.EnterElementId)); root.Children.Add(ButtonInfo(CharacterManagementUiController.DeleteElementId)); root.Children.Add(ButtonInfo(CharacterManagementUiController.RestoreElementId)); root.Children.Add(ButtonInfo(CharacterManagementUiController.CreditsElementId)); root.Children.Add(ButtonInfo(CharacterManagementUiController.ExitElementId)); return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } } private sealed class ManagementFakeRuntime { private static readonly RuntimeGenerationToken Generation = new(11u); private readonly FakeManagementView _view = new(); public ManagementFakeRuntime() { _view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)]; _view.Snapshot = new RuntimeCharacterSelectionSnapshot( Generation, RuntimeCharacterSelectionLifecycle.AwaitingSelection, Revision: 1, AccountName: "account", SlotCount: 5, RosterCount: _view.Entries.Length, WorldName: "sawato", HighlightedCharacterId: 0x50000001u, HighlightedDisplayIndex: 0, PendingDeleteCharacterId: 0u, LastRestoreRequestedCharacterId: 0u, Operation: RuntimeCharacterSelectionOperation.None, Error: null, Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false)); Bindings = new CharacterSelectionRuntimeBindings( View: () => _view, Highlight: _ => Result(), Enter: Result, RequestDelete: Result, ConfirmDelete: Result, Restore: Result, Cancel: Result, RequestExit: () => { }); } public CharacterSelectionRuntimeBindings Bindings { get; } private static RuntimeCommandResult Result() => new(RuntimeCommandStatus.Accepted, Generation); public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) { RuntimeCharacterSelectionSnapshot current = _view.Snapshot; _view.Snapshot = current with { Lifecycle = lifecycle, Revision = current.Revision + 1 }; } private sealed class FakeManagementView : IRuntimeCharacterSelectionView { public RuntimeCharacterSelectionEntry[] Entries { get; set; } = []; public RuntimeCharacterSelectionSnapshot Snapshot { get; set; } public bool TryGetAt(int displayIndex, out RuntimeCharacterSelectionEntry character) { if ((uint)displayIndex >= (uint)Entries.Length) { character = default; return false; } character = Entries[displayIndex]; return true; } public bool TryGet(uint characterId, out RuntimeCharacterSelectionEntry character) { int index = Array.FindIndex(Entries, entry => entry.CharacterId == characterId); if (index < 0) { character = default; return false; } character = Entries[index]; return true; } public void Visit(IRuntimeCharacterSelectionVisitor visitor) { foreach (RuntimeCharacterSelectionEntry character in Entries) visitor.Visit(in character); } public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) => NullSubscription.Instance; private sealed class NullSubscription : IDisposable { public static readonly NullSubscription Instance = new(); public void Dispose() { } } } } private sealed class ChargenHarness : IDisposable { private readonly List _dialogLayouts = []; private readonly RetailDialogFactory _dialogs; public ChargenHarness(UiRoot host) { Screen = BuildChargenScreen(); Runtime = new ChargenFakeRuntime(); _dialogs = new RetailDialogFactory(host, type => { ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type); _dialogLayouts.Add(layout); return layout; }); Controller = Assert.IsType( CharacterCreationUiController.CreateDetached( host, Screen, static (_, _) => null, _dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( "Are you sure you want to leave?"))); Controller.AttachAndTick(); } public ImportedLayout Screen { get; } public ChargenFakeRuntime Runtime { get; } public CharacterCreationUiController Controller { get; } public UiButton Button(uint id) => Assert.IsType(Screen.FindElement(id)); public void ConfirmActiveDialog(bool confirmed) { ImportedLayout dialog = _dialogLayouts[^1]; uint buttonId = confirmed ? RetailConfirmationDialogView.AcceptButtonId : RetailConfirmationDialogView.RejectButtonId; UiButton button = Assert.IsType(dialog.FindElement(buttonId)); button.OnClick!(); } public void Dispose() { Controller.Dispose(); _dialogs.Dispose(); } private static ImportedLayout BuildChargenScreen() { var root = new ElementInfo { Id = CharacterCreationUiController.RootElementId, Type = 3u, Width = 800f, Height = 600f, }; root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.HeritagePageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.ProfessionPageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.SkillsPageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.TownPageElementId)); root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId)); return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 200f, Height = 60f }; } private sealed class ChargenFakeRuntime { private static readonly RuntimeGenerationToken Generation = new(13u); public ChargenFakeRuntime() { View = new FakeChargenView(); Bindings = new CharacterCreationRuntimeBindings( () => ProvideView ? View : null, _ => Result(), _ => Result(), _ => Result(), (_, _) => Result(), (_, _) => Result(), _ => Result(), _ => Result(), _ => Result(), _ => Result(), _ => Result(), RequestExit: () => { }, ResolveText: _ => null, OpenOnStart: false); } public FakeChargenView View { get; } public CharacterCreationRuntimeBindings Bindings { get; } public bool ProvideView { get; set; } = true; private static RuntimeCommandResult Result() => new(RuntimeCommandStatus.Accepted, Generation); public sealed class FakeChargenView : IRuntimeCharacterCreationView { public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new( Generation, IsActive: true, Revision: 1, HeritageId: 0u, GenderKey: 0u, Appearance: RuntimeCharacterCreationAppearance.Default, Template: RuntimeCharacterCreationSnapshot.TemplateUnset, Attributes: default, AttributeLockMask: 0u, TotalAttributeCredits: 0u, RemainingAttributeCredits: 0, TotalSkillCredits: 0u, RemainingSkillCredits: 0, Name: string.Empty, StartArea: -1, Slot: 0u, VerificationPending: false, LastLocalRefusal: default, LastRejection: null, LastCreated: null); public ChargenOptions Options => ChargenOptions.Empty; public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive; public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance; private sealed class NullSubscription : IDisposable { public static readonly NullSubscription Instance = new(); public void Dispose() { } } } } private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 100f, Height = 30f }; }