Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
431 lines
18 KiB
C#
431 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15):
|
|
/// <see cref="CharacterCreationUiController"/> and
|
|
/// <see cref="CharacterManagementUiController"/> can be SIMULTANEOUSLY
|
|
/// active against the SAME <see cref="UiRoot"/> — character-creation opens
|
|
/// on top of character-management, which keeps ticking underneath it. Both
|
|
/// controllers share <see cref="UiRoot.FixedCanvasSize"/>, 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
|
|
/// <c>Close()</c> 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
|
|
/// <see cref="UiRoot.DeclareFixedCanvas"/>/<see cref="UiRoot.RevokeFixedCanvas"/>
|
|
/// across BOTH controllers on one shared host, per the reviewer's required
|
|
/// sequence.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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>(
|
|
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<ImportedLayout> _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>(
|
|
CharacterCreationUiController.CreateDetached(
|
|
host,
|
|
Screen,
|
|
static (_, _) => null,
|
|
_dialogs,
|
|
Runtime.Bindings,
|
|
new CharacterCreationUiController.DialogStrings(
|
|
"Are you sure you want to leave?",
|
|
"No name", "Unspent credits", "Randomize?", "Name too long")));
|
|
Controller.AttachAndTick();
|
|
}
|
|
|
|
public ImportedLayout Screen { get; }
|
|
public ChargenFakeRuntime Runtime { get; }
|
|
public CharacterCreationUiController Controller { get; }
|
|
|
|
public UiButton Button(uint id) =>
|
|
Assert.IsType<UiButton>(Screen.FindElement(id));
|
|
|
|
public void ConfirmActiveDialog(bool confirmed)
|
|
{
|
|
ImportedLayout dialog = _dialogLayouts[^1];
|
|
uint buttonId = confirmed
|
|
? RetailConfirmationDialogView.AcceptButtonId
|
|
: RetailConfirmationDialogView.RejectButtonId;
|
|
UiButton button = Assert.IsType<UiButton>(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 };
|
|
}
|