feat(ui): Campaign CT slice CT3 — Titles page live via standard GUI classes
Titles tab (AP-109's known-inert gap) now switches to a real page and CharacterTitlesController binds it entirely through UiTemplateListBox/ UiScrollbar/UiButton — zero bespoke widgets, matching every other social/options row-list page in this codebase. Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList @0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by resolved display text — this port rebuilds the full sorted set on every change rather than a positional splice, since UiTemplateListBox has no insert-at-index primitive and no other consumer needs one either); InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the same mechanism CT1's SEALED VERDICT confirmed for the stat rows); UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is selected whose id differs from the current display title — no selection IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including the hardcoded "Unknown" fallback, refreshed on both TableReplaced and DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle @0x006a5720 (wire-only TitleSet 0x002C send, no local mutation). CharacterStatController.Bind now three-way switches Attributes/Skills/ Titles — Titles is a genuinely separate, non-duplicated page container (CT1 ground truth §3), unlike Attributes/Skills which share one mounted page and only rebind content. The two page captions (0x1000052E/0x10000531) are left untouched: LayoutImporter.BuildText already resolves every element's authored StringInfo caption at import time, so no controller-side string lookup was added. New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands (InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape. CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle; CharacterTitleResolver (CT2) is constructed once at composition time and its .Resolve method group is passed to the controller as a delegate (not the concrete DAT-backed type) so the controller stays hermetically testable without a live IDatReaderWriter. Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs): binding-seam coverage against the REAL committed character_2100002E.json fixture (verified this session to already carry the Titles page subtree, including the ListBox's own authored TemplateList=[(0x2100005E, 0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds proves the controller reads that authored pair, not a hardcoded one); a hand-authored ElementInfo standing in only for the row template itself (a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT probe only); sorted-row order, Unknown fallback, row selection/highlight, the ghost truth table (no selection / selected==display / selected!= display), click-sends-exactly-one-SetTitle-and-mutates-nothing, click-while-ghosted-sends-nothing, TableReplaced rebuild (including selection survival when the id is still earned), TitleAdded single-row growth, DisplayTitleChanged text+ghost refresh, and Dispose unsubscription. CharacterStatControllerTests updated for the Titles tab no longer being ClickThrough, plus a new tab-switch visibility test. Register: amends AP-109 (docs/architecture/retail-divergence-register.md) to record the Titles-page half as LIVE; the header identity block and luminance fields remain open for CT4. Suites: full solution 15,405 tests / 0 skips (App 6,130) green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b5d36f5211
commit
03e073b748
8 changed files with 913 additions and 14 deletions
File diff suppressed because one or more lines are too long
|
|
@ -554,6 +554,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
RuntimeAdvancementKind.TrainSkill,
|
||||
statId,
|
||||
credits));
|
||||
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT
|
||||
// id -> display-string chain (CT2). Constructed once — its own
|
||||
// constructor does no DAT I/O (only .Resolve reads touch the
|
||||
// dats), matching the characterCreationStrings precedent below.
|
||||
var characterTitleResolver = new CharacterTitleResolver(d.Dats);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
|
||||
|
||||
uint MagicSkillLevel(MagicSchool school)
|
||||
|
|
@ -822,7 +827,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
&& d.Inventory.Objects.Get(guid) is { } vendorCandidate
|
||||
&& vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)),
|
||||
Character: new CharacterRuntimeBindings(characterSheet),
|
||||
Character: new CharacterRuntimeBindings(
|
||||
characterSheet,
|
||||
d.Character.Titles,
|
||||
characterTitleResolver,
|
||||
SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)),
|
||||
Inventory: new InventoryRuntimeBindings(
|
||||
d.Inventory.Objects,
|
||||
() => d.PlayerIdentity.ServerGuid,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
generation,
|
||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
||||
|
||||
/// <summary>Campaign CT slice CT3 (2026-08-24): retail <c>TitleSet
|
||||
/// (0x002C)</c> — same generation-capturing late seam as every other
|
||||
/// method here. <see cref="AcDream.App.UI.Layout.CharacterTitlesController"/>
|
||||
/// is the only caller.</summary>
|
||||
public RuntimeCommandResult SetTitle(uint titleId) =>
|
||||
Invoke((commands, generation) => commands.Character.SetTitle(
|
||||
generation,
|
||||
titleId));
|
||||
|
||||
// Campaign LA slice LA8: the retained character-management screen uses
|
||||
// the same generation-capturing late seam as every gameplay panel. The
|
||||
// screen never receives GameRuntime or WorldSession and cannot retain a
|
||||
|
|
|
|||
|
|
@ -35,10 +35,15 @@ namespace AcDream.App.UI.Layout;
|
|||
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
||||
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
||||
///
|
||||
/// <para>Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
||||
/// Source: <c>UIElement::SetState @ 0x00464E70</c> and UIStateId.Open (0x0C) /
|
||||
/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and
|
||||
/// propagates the state to its three chrome children through PassToChildren.</para>
|
||||
/// <para>Tab states: whichever of Attributes/Skills/Titles is active shows "Open"
|
||||
/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored
|
||||
/// default. Source: <c>UIElement::SetState @ 0x00464E70</c>. The imported Type-12
|
||||
/// tab owns its authored font color and propagates the state to its three chrome
|
||||
/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the
|
||||
/// Titles tab click to a real page switch (previously a known-inert AP-109 gap);
|
||||
/// <see cref="CharacterTitlesController"/> owns the Titles page's OWN content
|
||||
/// (row list, display-title text, Set-as-Display button) once its page container
|
||||
/// is shown.</para>
|
||||
///
|
||||
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
||||
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
||||
|
|
@ -146,6 +151,7 @@ public static class CharacterStatController
|
|||
{
|
||||
Attributes,
|
||||
Skills,
|
||||
Titles,
|
||||
}
|
||||
|
||||
public enum RaiseTargetKind
|
||||
|
|
@ -278,6 +284,11 @@ public static class CharacterStatController
|
|||
UiElement? skillsTab = layout.FindElement(TabSkillsId);
|
||||
UiElement? titlesTab = layout.FindElement(TabTitlesId);
|
||||
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
|
||||
// CT3: unlike Attributes/Skills (which share ONE mounted page and just
|
||||
// rebind its content — see the Attributes/Skills tab-switch note above),
|
||||
// the Titles page (0x10000539) is its OWN separate, non-duplicated subtree
|
||||
// (CT1 ground truth §3) that must be actually shown/hidden.
|
||||
UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId);
|
||||
|
||||
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||||
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
|
||||
|
|
@ -472,9 +483,12 @@ public static class CharacterStatController
|
|||
|
||||
RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes));
|
||||
RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills));
|
||||
// Titles remain the known AP-109 gap. Keep its retail-authored closed visual,
|
||||
// but do not make an inert page look interactive until that controller lands.
|
||||
RetailTabBinding.SetClick(titlesTab, null);
|
||||
// CT3 (2026-08-24): the Titles tab now switches to its own real page
|
||||
// (previously the known AP-109 gap — retail-authored closed visual, but
|
||||
// no click routing at all). Content population is
|
||||
// CharacterTitlesController's job, bound separately by the caller
|
||||
// against the SAME imported layout root.
|
||||
RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles));
|
||||
UpdateTabStates();
|
||||
|
||||
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||||
|
|
@ -501,8 +515,30 @@ public static class CharacterStatController
|
|||
attrSel[0] = -1;
|
||||
skillSel[0] = -1;
|
||||
SetFooterSelected(false);
|
||||
RebuildActiveList();
|
||||
RefreshActiveRaiseButtons();
|
||||
|
||||
// CT3: Titles is a genuinely separate page container (unlike
|
||||
// Attributes/Skills, which share one mounted page and only rebind
|
||||
// its content — see RebuildActiveList) — actually flip visibility
|
||||
// between it and the shared Attributes/Skills content page.
|
||||
bool showTitles = tab == CharacterStatTab.Titles;
|
||||
if (titlesPage is not null) titlesPage.Visible = showTitles;
|
||||
if (contentPage is not null) contentPage.Visible = !showTitles;
|
||||
|
||||
if (showTitles)
|
||||
{
|
||||
// Titles authors its own (unused) copies of the raise buttons
|
||||
// (CT1 ground truth); nothing on this page ever selects a stat
|
||||
// row, so keep them hidden rather than rebuilding a list this
|
||||
// tab does not show.
|
||||
foreach (var b in allRaise1) b.Visible = false;
|
||||
foreach (var b in allRaise10) b.Visible = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
RebuildActiveList();
|
||||
RefreshActiveRaiseButtons();
|
||||
}
|
||||
|
||||
UpdateTabStates();
|
||||
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
|
||||
}
|
||||
|
|
@ -511,7 +547,7 @@ public static class CharacterStatController
|
|||
{
|
||||
RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes);
|
||||
RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills);
|
||||
RetailTabBinding.SetOpen(titlesTab, false);
|
||||
RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles);
|
||||
}
|
||||
|
||||
void RebuildActiveList()
|
||||
|
|
|
|||
341
src/AcDream.App/UI/Layout/CharacterTitlesController.cs
Normal file
341
src/AcDream.App/UI/Layout/CharacterTitlesController.cs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles
|
||||
/// page (LayoutDesc <c>0x2100002E</c>, element <c>0x10000539</c> —
|
||||
/// <c>gmCharacterTitleUI</c>) to CT2's <see cref="RuntimeCharacterTitleState"/>
|
||||
/// owner through the standard <see cref="UiTemplateListBox"/>/
|
||||
/// <see cref="UiScrollbar"/>/<see cref="UiButton"/> classes only — no bespoke
|
||||
/// widgets, matching every other social/options row-list page in this
|
||||
/// codebase (<see cref="SocialFriendsPageController"/>,
|
||||
/// <see cref="SocialFellowshipPageController"/>,
|
||||
/// <see cref="SocialAllegiancePageController"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Rows.</b> <c>AddTitleToList @0x0049A840</c> resolves each row's text
|
||||
/// through <c>CharacterTitleTable::GetCharacterTitleFromID</c> (ported as
|
||||
/// <see cref="CharacterTitleResolver"/>, CT2) and inserts it SORTED
|
||||
/// (<c>FindSortedInsertPosition @0x0049A760</c> — an ordinal string sort on
|
||||
/// the resolved display text). This port rebuilds the full sorted row set on
|
||||
/// every change (<see cref="RebuildRows"/>) rather than performing a true
|
||||
/// positional splice: <see cref="UiTemplateListBox"/> has no insert-at-index
|
||||
/// primitive, and no other <see cref="UiTemplateListBox"/> consumer in this
|
||||
/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen
|
||||
/// skills/the Options tabs all rebuild-on-change the same way) — the
|
||||
/// resulting VISIBLE order is retail-exact even though the underlying
|
||||
/// mechanism is "rebuild," not "splice."
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Selection + highlight.</b> Retail's <c>InfoRegion::SetState</c>
|
||||
/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT)
|
||||
/// applies <c>SetState(selected ? 6 : 1)</c> directly to the row element —
|
||||
/// state 6 is <see cref="UiButtonStateMachine.Highlight"/>. The title row
|
||||
/// template (<c>0x10000536</c>) authors that exact Highlight state
|
||||
/// (<c>0x06001AAF</c>) alongside its DirectState background
|
||||
/// (<c>0x06004CCA</c>), so this controller uses the row's own
|
||||
/// <see cref="IUiDatStateful.TrySetRetailState"/> — no synthesized color
|
||||
/// swap, unlike pages whose row template lacks a state-based highlight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The "Set as Display Title" button (<c>0x10000535</c>).</b>
|
||||
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED per the campaign plan's CT1
|
||||
/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id
|
||||
/// DIFFERS from the CURRENT display title; no selection is the Ghosted case,
|
||||
/// not the enabled one. A click sends
|
||||
/// <c>CM_Social::Event_SetDisplayCharacterTitle</c> (CT2's
|
||||
/// <see cref="RuntimeCharacterTitleState"/> command seam) — wire only, no
|
||||
/// local mutation; the ghost gate itself makes an already-current selection
|
||||
/// unreachable from the UI, so the click handler's own defensive re-check is
|
||||
/// belt-and-braces, not the primary guard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Display-title text (<c>0x1000052F</c>).</b> <c>Refresh @0x0049abc0</c>
|
||||
/// shows the resolved current display title, or retail's hardcoded literal
|
||||
/// <c>"Unknown"</c> when the id does not resolve — refreshed on BOTH
|
||||
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (retail's own
|
||||
/// <c>RecvNotice_UpdateCharacterTitleTable</c> unconditionally calls
|
||||
/// <c>Refresh()</c> on every <c>0x0029</c>, CT2 review anchor 1) and
|
||||
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The two page captions (<c>0x1000052E</c>/<c>0x10000531</c>).</b> Left
|
||||
/// untouched by this controller — <see cref="LayoutImporter.BuildText"/>
|
||||
/// already resolves every element's authored <c>StringInfo</c> caption at
|
||||
/// import time (<c>ResolveAuthoredString</c>), the SAME mechanism every
|
||||
/// other DAT-authored label in this window already relies on, so no
|
||||
/// controller-side string lookup is needed or added here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CharacterTitlesController : IDisposable
|
||||
{
|
||||
public const uint CurrentDisplayTitleTextId = 0x1000052Fu;
|
||||
public const uint TitleListBoxId = 0x10000532u;
|
||||
public const uint SetDisplayButtonId = 0x10000535u;
|
||||
|
||||
/// <summary>The row template's own text child (<c>0x10000536</c>'s
|
||||
/// single Type-0xC child) — CT1 ground truth §3.</summary>
|
||||
private const uint RowTextId = 0x10000537u;
|
||||
|
||||
/// <summary>Retail's hardcoded fallback literal (<c>Refresh
|
||||
/// @0x0049abc0</c>) for a display title id that does not resolve —
|
||||
/// ported verbatim, not a StringTable key (CT2 review anchor 3).</summary>
|
||||
private const string UnknownTitleText = "Unknown";
|
||||
|
||||
private readonly record struct Row(UiElement Root, uint TitleId);
|
||||
|
||||
private readonly RuntimeCharacterTitleState _titles;
|
||||
private readonly Func<uint, string?> _resolveTitle;
|
||||
private readonly Func<uint, RuntimeCommandResult> _sendSetTitle;
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly UiText? _displayText;
|
||||
private readonly UiButton? _setDisplayButton;
|
||||
private readonly List<Row> _rows = new();
|
||||
private uint? _selectedTitleId;
|
||||
private bool _disposed;
|
||||
|
||||
private CharacterTitlesController(
|
||||
RuntimeCharacterTitleState titles,
|
||||
Func<uint, string?> resolveTitle,
|
||||
Func<uint, RuntimeCommandResult> sendSetTitle,
|
||||
UiTemplateListBox listBox,
|
||||
UiText? displayText,
|
||||
UiButton? setDisplayButton)
|
||||
{
|
||||
_titles = titles;
|
||||
_resolveTitle = resolveTitle;
|
||||
_sendSetTitle = sendSetTitle;
|
||||
_listBox = listBox;
|
||||
_displayText = displayText;
|
||||
_setDisplayButton = setDisplayButton;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the Titles page's list box, scrollbar, display-title text, and
|
||||
/// Set-as-Display button under <paramref name="layoutRoot"/> (the
|
||||
/// character window's imported tree — the Titles page's element ids are
|
||||
/// unique client-wide, so no page-scoped search is needed, unlike the
|
||||
/// multi-tab social panel's row families). Returns null (logging why)
|
||||
/// when the list box itself is missing — every other element is
|
||||
/// optional so a partial import still gets what it can.
|
||||
/// </summary>
|
||||
/// <param name="resolveTitle">CT2's <see cref="CharacterTitleResolver.Resolve(uint)"/>
|
||||
/// method group in production; a delegate (not the concrete DAT-backed
|
||||
/// class) so this controller stays hermetically testable without a live
|
||||
/// <c>IDatReaderWriter</c>.</param>
|
||||
public static CharacterTitlesController? Bind(
|
||||
UiElement layoutRoot,
|
||||
RuntimeCharacterTitleState titles,
|
||||
Func<uint, string?> resolveTitle,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, RuntimeCommandResult> sendSetTitle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layoutRoot);
|
||||
ArgumentNullException.ThrowIfNull(titles);
|
||||
ArgumentNullException.ThrowIfNull(resolveTitle);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
ArgumentNullException.ThrowIfNull(sendSetTitle);
|
||||
|
||||
if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " +
|
||||
"found — the Titles page will not populate.");
|
||||
return null;
|
||||
}
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
uint scrollbarElementId = listBox.ScrollbarElementId;
|
||||
UiElement? scrollbarElement = scrollbarElementId == 0
|
||||
? null
|
||||
: UiElement.FindDescendant(layoutRoot, scrollbarElementId);
|
||||
if (scrollbarElement is UiScrollbar scrollbar)
|
||||
scrollbar.Model = listBox.Scroll;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " +
|
||||
"not found — the Titles list will not scroll.");
|
||||
|
||||
UiText? displayText =
|
||||
UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText;
|
||||
UiButton? setDisplayButton =
|
||||
UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton;
|
||||
|
||||
var controller = new CharacterTitlesController(
|
||||
titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton);
|
||||
controller.WireButton();
|
||||
|
||||
titles.TableReplaced += controller.OnTableReplaced;
|
||||
titles.TitleAdded += controller.OnTitleAdded;
|
||||
titles.DisplayTitleChanged += controller.OnDisplayTitleChanged;
|
||||
|
||||
controller.RebuildRows();
|
||||
controller.RefreshDisplayText();
|
||||
controller.RefreshButtonGhost();
|
||||
return controller;
|
||||
}
|
||||
|
||||
private void WireButton()
|
||||
{
|
||||
if (_setDisplayButton is null) return;
|
||||
_setDisplayButton.OnClick = () =>
|
||||
{
|
||||
// Belt-and-braces re-check (CT2 review anchor 2): retail's real
|
||||
// guard is the Ghosted state itself — UiButton refuses to raise
|
||||
// OnClick while !Enabled — so this branch is normally
|
||||
// unreachable from a real click, but a direct-call test (or a
|
||||
// stray event) must still send nothing while ghosted, and never
|
||||
// wait for a confirmation ACE does not send when re-setting the
|
||||
// already-current title.
|
||||
if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId)
|
||||
return;
|
||||
_sendSetTitle(id);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x0029 CharacterTitle</c> — retail's own <c>Refresh()</c> is
|
||||
/// unconditional here (CT2 review anchor 1), and <c>UnPack</c> always
|
||||
/// rebuilds <c>mTitleList</c> from scratch. <see cref="RebuildRows"/>
|
||||
/// itself decides whether the current selection survives (it does when
|
||||
/// the selected id is still earned in the new table).
|
||||
/// </summary>
|
||||
private void OnTableReplaced()
|
||||
{
|
||||
RebuildRows();
|
||||
RefreshDisplayText();
|
||||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x002B UpdateTitle</c>, add half — CT2's F1 fix already dedupes
|
||||
/// this event to genuine new memberships only (a repeat add fires no
|
||||
/// event at all), so every firing here is a real new row.
|
||||
/// </summary>
|
||||
private void OnTitleAdded(uint titleId)
|
||||
{
|
||||
RebuildRows();
|
||||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
private void OnDisplayTitleChanged(uint titleId)
|
||||
{
|
||||
RefreshDisplayText();
|
||||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full sorted rebuild — see the class remarks for why this port
|
||||
/// rebuilds rather than performing retail's literal single-row
|
||||
/// positional insert. Preserves scroll position
|
||||
/// (<see cref="UiTemplateListBox.FlushPreservingScroll"/>). The current
|
||||
/// selection survives when the selected id is still present in the
|
||||
/// rebuilt row set; otherwise it is cleared here so the Set-as-Display
|
||||
/// button's ghost state can never desync from what is actually
|
||||
/// highlighted (a selection pointing at a no-longer-visible row would
|
||||
/// leave the button enabled with nothing shown selected).
|
||||
/// </summary>
|
||||
private void RebuildRows()
|
||||
{
|
||||
_listBox.FlushPreservingScroll();
|
||||
_rows.Clear();
|
||||
|
||||
// A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per
|
||||
// read — safe here (a UI refresh call site, not a per-frame poll).
|
||||
var sorted = new List<(uint Id, string Text)>();
|
||||
foreach (uint id in _titles.EarnedTitleIds)
|
||||
sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText));
|
||||
// FindSortedInsertPosition @0x0049A760: ordinal string sort on the
|
||||
// resolved display text.
|
||||
sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text));
|
||||
|
||||
foreach ((uint id, string text) in sorted)
|
||||
{
|
||||
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||
if (row is null) continue;
|
||||
|
||||
if (row is UiDatElement datRow)
|
||||
{
|
||||
// Generic Type-3 container fallback (DatWidgetFactory) —
|
||||
// "generic decoration; behavioral widgets opt back in" (its
|
||||
// own class doc). Same page-opt-in shape
|
||||
// CharacterCreationSkillsPage uses for its selectable rows.
|
||||
datRow.ClickThrough = false;
|
||||
uint capturedId = id;
|
||||
datRow.OnClick = () => SelectRow(capturedId);
|
||||
}
|
||||
|
||||
if (UiElement.FindDescendant(row, RowTextId) is UiText rowText)
|
||||
{
|
||||
string capturedText = text;
|
||||
rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)];
|
||||
}
|
||||
|
||||
_rows.Add(new Row(row, id));
|
||||
}
|
||||
|
||||
if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected))
|
||||
_selectedTitleId = null;
|
||||
|
||||
ApplyRowHighlights();
|
||||
}
|
||||
|
||||
private void SelectRow(uint titleId)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_selectedTitleId = titleId;
|
||||
ApplyRowHighlights();
|
||||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>InfoRegion::SetState(selected ? 6 : 1)</c> — the
|
||||
/// row's OWN authored Highlight/DirectState media swap, not a
|
||||
/// synthesized color (see class remarks).</summary>
|
||||
private void ApplyRowHighlights()
|
||||
{
|
||||
foreach (Row row in _rows)
|
||||
{
|
||||
if (row.Root is IUiDatStateful stateful)
|
||||
{
|
||||
stateful.TrySetRetailState(
|
||||
row.TitleId == _selectedTitleId
|
||||
? UiButtonStateMachine.Highlight
|
||||
: UiButtonStateMachine.Normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshDisplayText()
|
||||
{
|
||||
if (_displayText is null) return;
|
||||
string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText;
|
||||
_displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED — campaign plan CT1 fix
|
||||
/// round): Ghosted UNLESS a row is selected whose title id DIFFERS from
|
||||
/// the current display title. No selection is the Ghosted case.
|
||||
/// </summary>
|
||||
private void RefreshButtonGhost()
|
||||
{
|
||||
if (_setDisplayButton is null) return;
|
||||
bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId;
|
||||
_setDisplayButton.TrySetRetailState(
|
||||
shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_titles.TableReplaced -= OnTableReplaced;
|
||||
_titles.TitleAdded -= OnTitleAdded;
|
||||
_titles.DisplayTitleChanged -= OnDisplayTitleChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +156,22 @@ public sealed record ToolbarRuntimeBindings(
|
|||
// vendor-owned split-exempt-seed predicate (research doc §C.1).
|
||||
Func<uint, bool> IsVendorSplitExempt);
|
||||
|
||||
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
|
||||
/// <param name="Provider">The sheet + raise-request flow (pre-existing).</param>
|
||||
/// <param name="Titles">Campaign CT slice CT2's <c>RuntimeCharacterTitleState</c>
|
||||
/// owner — CT3's <see cref="Layout.CharacterTitlesController"/> binds
|
||||
/// directly against it.</param>
|
||||
/// <param name="TitleResolver">CT2's DAT id->string chain
|
||||
/// (<c>CharacterTitleTable::GetCharacterTitleFromID</c>). Constructed once at
|
||||
/// composition time — its own constructor does no DAT I/O.</param>
|
||||
/// <param name="SendSetTitle">Retail <c>TitleSet (0x002C)</c> — wire only, no
|
||||
/// local mutation (CT2's <c>IRuntimeCharacterCommands.SetTitle</c> via the
|
||||
/// late-bound game-runtime command seam, same shape as every fellowship/
|
||||
/// allegiance command above).</param>
|
||||
public sealed record CharacterRuntimeBindings(
|
||||
CharacterSheetProvider Provider,
|
||||
RuntimeCharacterTitleState Titles,
|
||||
CharacterTitleResolver TitleResolver,
|
||||
Func<uint, RuntimeCommandResult> SendSetTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel
|
||||
|
|
@ -532,6 +547,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
||||
private IDisposable? _characterSheetSubscription;
|
||||
private Layout.CharacterTitlesController? _characterTitlesController;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -4040,6 +4056,37 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
currentSheet = provider.BuildSheet();
|
||||
refreshRows();
|
||||
});
|
||||
|
||||
// CT3 (2026-08-24): the Titles page's row template lives in a
|
||||
// SEPARATE LayoutDesc (0x2100005E, element 0x10000536 — CT1 ground
|
||||
// truth §3), reachable only through the targeted single-root
|
||||
// ImportInfos overload — same caching resolver shape the social
|
||||
// panel's Friends/Fellowship/Allegiance row families already use.
|
||||
var titleRowTemplates = new Layout.RowTemplateResolver(
|
||||
(layoutId, elementId) => LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, layoutId, elementId),
|
||||
info =>
|
||||
{
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
return LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve).Root;
|
||||
});
|
||||
UiElement? TitleTemplateResolver(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return titleRowTemplates.Resolve(templateLayoutId, templateElementId);
|
||||
}
|
||||
_characterTitlesController = Layout.CharacterTitlesController.Bind(
|
||||
layout.Root,
|
||||
_bindings.Character.Titles,
|
||||
_bindings.Character.TitleResolver.Resolve,
|
||||
TitleTemplateResolver,
|
||||
_bindings.Character.SendSetTitle);
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
layout.Root,
|
||||
|
|
@ -4799,6 +4846,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
() =>
|
||||
{
|
||||
_characterSheetSubscription?.Dispose();
|
||||
_characterTitlesController?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
WindowLockPresentation.Dispose();
|
||||
WindowOpacity.Dispose();
|
||||
|
|
|
|||
|
|
@ -802,7 +802,10 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(
|
||||
new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u },
|
||||
skills.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
|
||||
Assert.True(titles.ClickThrough);
|
||||
// CT3 (2026-08-24): the Titles tab is no longer inert — it switches
|
||||
// to a real page (previously AP-109's known gap).
|
||||
Assert.False(titles.ClickThrough);
|
||||
Assert.NotNull(titles.OnClick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -826,6 +829,45 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted
|
||||
/// page and only rebind its content), the Titles page (0x10000539) is a
|
||||
/// genuinely separate, non-duplicated subtree (CT1 ground truth §3) that
|
||||
/// must actually be shown/hidden on tab switch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TitlesTab_Click_ShowsTitlesPageAndHidesAttributesSkillsContent()
|
||||
{
|
||||
var layout = FixtureLoader.LoadCharacter();
|
||||
var titlesTab = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabTitlesId));
|
||||
var attributesTab = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
|
||||
var titlesPage = layout.FindElement(CharacterStatController.TitlesPageId);
|
||||
var attributesPage = layout.FindElement(CharacterStatController.AttributesPageId);
|
||||
Assert.NotNull(titlesPage);
|
||||
Assert.NotNull(attributesPage);
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
Assert.True(attributesPage!.Visible);
|
||||
Assert.False(titlesPage!.Visible);
|
||||
|
||||
titlesTab.OnClick!();
|
||||
|
||||
Assert.True(titlesPage.Visible);
|
||||
Assert.False(attributesPage.Visible);
|
||||
Assert.Equal(RetailUiStateIds.Open, titlesTab.ActiveRetailStateId);
|
||||
Assert.Equal(RetailUiStateIds.Closed, attributesTab.ActiveRetailStateId);
|
||||
|
||||
// Switching back to Attributes restores the shared content page and
|
||||
// re-hides Titles.
|
||||
attributesTab.OnClick!();
|
||||
|
||||
Assert.True(attributesPage.Visible);
|
||||
Assert.False(titlesPage.Visible);
|
||||
Assert.Equal(RetailUiStateIds.Closed, titlesTab.ActiveRetailStateId);
|
||||
}
|
||||
|
||||
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for
|
||||
/// <see cref="CharacterTitlesController"/>. Binding-seam coverage
|
||||
/// (<c>feedback_test_the_binding_seam.md</c>) uses the REAL committed
|
||||
/// <c>character_2100002E.json</c> fixture — verified (2026-08-24) to already
|
||||
/// carry the whole Titles page subtree, including the ListBox's own
|
||||
/// authored <c>TemplateList</c> entry pointing at <c>(0x2100005E,
|
||||
/// 0x10000536)</c> — so every element this controller binds except the row
|
||||
/// template ITSELF (a separate LayoutDesc CT1 could only reach via a live
|
||||
/// DAT probe, with no committed fixture yet) comes from the real imported
|
||||
/// tree, not a hand-built stand-in.
|
||||
/// </summary>
|
||||
public sealed class CharacterTitlesControllerTests
|
||||
{
|
||||
// Row template ground truth (docs/research/2026-08-24-campaign-ct-dat-
|
||||
// ground-truth.md §3): LayoutDesc 0x2100005E, element 0x10000536 — a
|
||||
// 270x24 Type-3 container with DirectState/Highlight media and one
|
||||
// Type-0xC text child (0x10000537).
|
||||
private const uint RowTemplateLayoutId = 0x2100005Eu;
|
||||
private const uint RowTemplateElementId = 0x10000536u;
|
||||
private const uint RowTextId = 0x10000537u;
|
||||
private const uint RowNormalSprite = 0x06004CCAu;
|
||||
private const uint RowHighlightSprite = 0x06001AAFu;
|
||||
|
||||
private static ElementInfo BuildRowTemplateInfo()
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = RowTemplateElementId,
|
||||
Type = 3u,
|
||||
Width = 270f,
|
||||
Height = 24f,
|
||||
};
|
||||
info.StateMedia[""] = (RowNormalSprite, 3);
|
||||
info.StateMedia["Highlight"] = (RowHighlightSprite, 1);
|
||||
info.Children.Add(new ElementInfo
|
||||
{
|
||||
Id = RowTextId,
|
||||
Type = 0xCu,
|
||||
Width = 270f,
|
||||
Height = 24f,
|
||||
HJustify = HJustify.Left,
|
||||
FontColor = Vector4.One,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
||||
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
|
||||
=> LayoutImporter.Build(BuildRowTemplateInfo(), static _ => (0u, 0, 0), null).Root;
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required ImportedLayout Layout;
|
||||
public required UiTemplateListBox ListBox;
|
||||
public required UiText DisplayText;
|
||||
public required UiButton SetDisplayButton;
|
||||
public required RuntimeCharacterTitleState Titles;
|
||||
public required Dictionary<uint, string> Names;
|
||||
public required List<uint> SentTitleIds;
|
||||
public required CharacterTitlesController Controller;
|
||||
|
||||
public IReadOnlyList<UiElement> Rows =>
|
||||
ListBox.ViewportForTest?.Children ?? [];
|
||||
|
||||
public string RowText(UiElement row) =>
|
||||
((UiText)UiElement.FindDescendant(row, RowTextId)!).LinesProvider().Single().Text;
|
||||
|
||||
public uint RowMedia(UiElement row) =>
|
||||
((UiDatElement)row).ActiveMedia().File;
|
||||
}
|
||||
|
||||
// ── Binding seam ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_FindsEveryTitlesPageElement_InTheRealImportedFixture()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([], displayTitleId: 0u);
|
||||
|
||||
Assert.NotNull(h.Controller);
|
||||
Assert.Equal(CharacterTitlesController.TitleListBoxId, h.ListBox.DatElementId);
|
||||
Assert.Equal(CharacterTitlesController.CurrentDisplayTitleTextId, h.DisplayText.DatElementId);
|
||||
Assert.Equal(CharacterTitlesController.SetDisplayButtonId, h.SetDisplayButton.DatElementId);
|
||||
// The authored scrollbar (0x10000533, the ListBox's own
|
||||
// ScrollbarElementId) must actually be wired to the list's scroll
|
||||
// model, not merely present.
|
||||
var scrollbar = Assert.IsType<UiScrollbar>(
|
||||
h.Layout.FindElement(h.ListBox.ScrollbarElementId));
|
||||
Assert.Same(h.ListBox.Scroll, scrollbar.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_MissingListBox_ReturnsNullWithoutThrowing()
|
||||
{
|
||||
var root = new UiPanel();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
|
||||
CharacterTitlesController? controller = CharacterTitlesController.Bind(
|
||||
root,
|
||||
titles,
|
||||
static _ => null,
|
||||
FakeRowTemplateResolver,
|
||||
static _ => new RuntimeCommandResult(RuntimeCommandStatus.Inactive, default));
|
||||
|
||||
Assert.Null(controller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds()
|
||||
{
|
||||
// The REAL fixture's ListBox (0x10000532) authors TemplateList =
|
||||
// [(0x2100005E, 0x10000536)] (dat property 0x64) — this proves the
|
||||
// controller's AddItemFromTemplateList(0) call actually reads that
|
||||
// authored entry rather than a hardcoded pair of its own.
|
||||
var seen = new List<(uint LayoutId, uint ElementId)>();
|
||||
UiElement? Recording(uint layoutId, uint elementId)
|
||||
{
|
||||
seen.Add((layoutId, elementId));
|
||||
return FakeRowTemplateResolver(layoutId, elementId);
|
||||
}
|
||||
|
||||
BindWithEarnedTitles(
|
||||
[1u], displayTitleId: 0u,
|
||||
names: new() { [1u] = "Adventurer" },
|
||||
rowResolver: Recording);
|
||||
|
||||
(uint layoutId, uint elementId) = Assert.Single(seen);
|
||||
Assert.Equal(RowTemplateLayoutId, layoutId);
|
||||
Assert.Equal(RowTemplateElementId, elementId);
|
||||
}
|
||||
|
||||
private static Harness BindWithEarnedTitles(
|
||||
IReadOnlyList<uint> earnedIds,
|
||||
uint displayTitleId,
|
||||
Dictionary<uint, string>? names = null,
|
||||
Func<uint, uint, UiElement?>? rowResolver = null)
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadCharacter();
|
||||
var titles = new RuntimeCharacterTitleState();
|
||||
titles.ReplaceTable(displayTitleId, earnedIds.ToArray());
|
||||
Dictionary<uint, string> resolvedNames = names ?? new Dictionary<uint, string>();
|
||||
var sent = new List<uint>();
|
||||
|
||||
RuntimeCommandResult SendSetTitle(uint id)
|
||||
{
|
||||
sent.Add(id);
|
||||
return new RuntimeCommandResult(RuntimeCommandStatus.Accepted, default);
|
||||
}
|
||||
|
||||
string? ResolveTitle(uint id) =>
|
||||
resolvedNames.TryGetValue(id, out string? name) ? name : null;
|
||||
|
||||
CharacterTitlesController? controller = CharacterTitlesController.Bind(
|
||||
layout.Root,
|
||||
titles,
|
||||
ResolveTitle,
|
||||
rowResolver ?? FakeRowTemplateResolver,
|
||||
SendSetTitle);
|
||||
Assert.NotNull(controller);
|
||||
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
layout.FindElement(CharacterTitlesController.TitleListBoxId));
|
||||
var displayText = Assert.IsType<UiText>(
|
||||
layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId));
|
||||
var button = Assert.IsType<UiButton>(
|
||||
layout.FindElement(CharacterTitlesController.SetDisplayButtonId));
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Layout = layout,
|
||||
ListBox = listBox,
|
||||
DisplayText = displayText,
|
||||
SetDisplayButton = button,
|
||||
Titles = titles,
|
||||
Names = resolvedNames,
|
||||
SentTitleIds = sent,
|
||||
Controller = controller!,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Row sort + content ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Rows_AreSortedAlphabeticallyByResolvedTitleText()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[13u, 5u, 1u],
|
||||
displayTitleId: 0u,
|
||||
names: new()
|
||||
{
|
||||
[1u] = "Adventurer",
|
||||
[5u] = "Life Mage",
|
||||
[13u] = "War Mage",
|
||||
});
|
||||
|
||||
Assert.Equal(3, h.Rows.Count);
|
||||
Assert.Equal(
|
||||
["Adventurer", "Life Mage", "War Mage"],
|
||||
h.Rows.Select(h.RowText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[99u],
|
||||
displayTitleId: 0u,
|
||||
names: []);
|
||||
|
||||
UiElement row = Assert.Single(h.Rows);
|
||||
Assert.Equal("Unknown", h.RowText(row));
|
||||
}
|
||||
|
||||
// ── Selection + highlight ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SelectingARow_AppliesHighlightMedia_AndDeselectsTheOthers()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 5u],
|
||||
displayTitleId: 0u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
UiElement first = h.Rows[0];
|
||||
UiElement second = h.Rows[1];
|
||||
|
||||
((UiDatElement)first).OnClick!();
|
||||
|
||||
Assert.Equal(RowHighlightSprite, h.RowMedia(first));
|
||||
Assert.Equal(RowNormalSprite, h.RowMedia(second));
|
||||
|
||||
((UiDatElement)second).OnClick!();
|
||||
|
||||
Assert.Equal(RowNormalSprite, h.RowMedia(first));
|
||||
Assert.Equal(RowHighlightSprite, h.RowMedia(second));
|
||||
}
|
||||
|
||||
// ── Ghost truth table (UpdateButtons @0x0049A500, CORRECTED direction) ─
|
||||
|
||||
[Fact]
|
||||
public void Ghost_NoSelection_ButtonIsGhosted()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
|
||||
|
||||
Assert.False(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_SelectedRowEqualsCurrentDisplayTitle_ButtonIsGhosted()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 5u], displayTitleId: 5u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
// Sorted order: Adventurer(1), Life Mage(5) — select the row whose
|
||||
// id equals the current display title (5).
|
||||
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
|
||||
|
||||
((UiDatElement)lifeMageRow).OnClick!();
|
||||
|
||||
Assert.False(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_SelectedRowDiffersFromCurrentDisplayTitle_ButtonIsNormal()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 5u], displayTitleId: 5u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer");
|
||||
|
||||
((UiDatElement)adventurerRow).OnClick!();
|
||||
|
||||
Assert.True(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
// ── Click -> SetTitle wire send ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ClickingSetDisplay_SendsExactlyOneSetTitleWithTheSelectedId_AndMutatesNothingLocally()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 13u], displayTitleId: 1u,
|
||||
names: new() { [1u] = "Adventurer", [13u] = "War Mage" });
|
||||
UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage");
|
||||
((UiDatElement)warMageRow).OnClick!();
|
||||
|
||||
h.SetDisplayButton.OnClick!();
|
||||
|
||||
Assert.Equal([13u], h.SentTitleIds);
|
||||
// No optimistic local mutation — CT2's own contract (ACE sends no
|
||||
// echo when re-setting the current title; the display title only
|
||||
// ever changes from a DisplayTitleChanged event).
|
||||
Assert.Equal(1u, h.Titles.DisplayTitleId);
|
||||
Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickingSetDisplay_WhileGhosted_SendsNothing()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
|
||||
|
||||
// No row selected -> Ghosted. A direct OnClick invocation bypasses
|
||||
// UiButton's own Enabled-gated event routing, so this exercises the
|
||||
// controller's own belt-and-braces re-check.
|
||||
h.SetDisplayButton.OnClick!();
|
||||
|
||||
Assert.Empty(h.SentTitleIds);
|
||||
}
|
||||
|
||||
// ── Wire events ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TableReplaced_RebuildsRows_AndClearsSelection()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" });
|
||||
((UiDatElement)h.Rows[0]).OnClick!();
|
||||
Assert.True(h.SetDisplayButton.Enabled); // selected, differs from display(0)
|
||||
|
||||
h.Titles.ReplaceTable(0u, [13u]);
|
||||
|
||||
Assert.Equal(["War Mage"], h.Rows.Select(h.RowText));
|
||||
// The previously-selected row no longer exists post-rebuild -> back
|
||||
// to the no-selection Ghosted state.
|
||||
Assert.False(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 5u], displayTitleId: 0u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
|
||||
((UiDatElement)lifeMageRow).OnClick!();
|
||||
Assert.True(h.SetDisplayButton.Enabled);
|
||||
|
||||
// A resend of the SAME table (retail's own Refresh() is
|
||||
// unconditional — CT2 review anchor 1) must not silently desync the
|
||||
// ghost state from the still-valid selection.
|
||||
h.Titles.ReplaceTable(0u, [1u, 5u]);
|
||||
|
||||
UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
|
||||
Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow));
|
||||
Assert.True(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TitleAdded_InsertsExactlyOneRow_PreservingExistingRowsInSortedOrder()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u], displayTitleId: 0u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
Assert.Single(h.Rows);
|
||||
|
||||
h.Titles.ApplyUpdateTitle(5u, setAsDisplay: false);
|
||||
|
||||
Assert.Equal(["Adventurer", "Life Mage"], h.Rows.Select(h.RowText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayTitleChanged_UpdatesTextAndReevaluatesGhost()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles(
|
||||
[1u, 5u], displayTitleId: 0u,
|
||||
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
|
||||
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
|
||||
((UiDatElement)lifeMageRow).OnClick!();
|
||||
Assert.True(h.SetDisplayButton.Enabled); // selected(5) != display(0)
|
||||
|
||||
// Simulates the server echo (0x002B UpdateTitle, setAsDisplay=true)
|
||||
// that a real Set-as-Display send would eventually produce.
|
||||
h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true);
|
||||
|
||||
Assert.Equal("Life Mage", h.DisplayText.LinesProvider().Single().Text);
|
||||
// Selection now equals the (new) current display title -> Ghosted.
|
||||
Assert.False(h.SetDisplayButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayTitleText_UnresolvedId_ShowsRetailUnknownLiteral()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([1u], displayTitleId: 77u, names: new() { [1u] = "Adventurer" });
|
||||
|
||||
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayTitleText_NoDisplayTitleSet_ShowsRetailUnknownLiteral()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
|
||||
|
||||
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromTitleEvents()
|
||||
{
|
||||
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
|
||||
|
||||
h.Controller.Dispose();
|
||||
|
||||
// Must not throw, and must not rebuild the (now-orphaned) rows.
|
||||
h.Titles.ReplaceTable(0u, [1u, 5u]);
|
||||
Assert.Single(h.Rows);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue