feat(studio): Attributes tab Pass 2 — click-to-select (highlight + footer B + raise triangles) + tab states

- UiClickablePanel: new UiPanel subclass with OnClick action + HandlesClick=true
  so row clicks survive whole-window-Draggable ancestor frames.

- CharacterStatController overhaul (Pass 2):
  - Tab bar button states: SetButtonStateRecursive walks each tab group container
    (0x10000228/229/538) setting UiButton.ActiveState="Open" (Attributes) or
    "Closed" (Skills/Titles) via UIStateId enum string names from DatReaderWriter.
  - Row click: 9 rows become UiClickablePanel; sel[] mutable box drives footer/highlight.
  - Toggle: clicking the same row deselects (→ footer State A); click a new row
    updates title="Attrib: value", line-1 label="Experience To Raise:", line-1
    value=cost, line-2="Unassigned Experience:" in both states.
  - Row highlight: BackgroundColor=HighlightBg (semi-translucent gold) on selected row.
  - Raise buttons (0x10000246 ×1 + 0x100005EB ×10): hidden initially; shown on
    selection with ActiveState="Normal" (affordable) or "Ghosted" (cost=0 or unaffordable).
    CollectButtonsById tree-walk finds ALL copies of the button across tab-page mounts
    (not just the last-registered _byId copy) so all instances are controlled.

- CharacterSheet: AttributeRaiseCosts long[] (Strength…Mana raise costs in retail
  display order; cost=0 → max/disabled row demos the Ghosted button state).
- SampleData.SampleCharacter: fills AttributeRaiseCosts[9] — Strength/Quickness=0
  (maxed), Focus@10→110 matching the retail screenshot (spec §4).

- 35 new tests (total 673 pass) covering: row click→footer B title/line1/line2,
  toggle deselect→footer A, switch row, highlight set/clear, raise button
  hidden/Normal/Ghosted/deselect, tab Open/Closed states, GetRaiseCost helper,
  GetRowName helper, SampleData fixture sanity.

Console.WriteLine("[CharacterStat] Row click: index=N → selected=N (Name)") fires
on every click for the user's live verification in the studio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 21:48:16 +02:00
parent 21d8485053
commit defbde1f86
5 changed files with 817 additions and 145 deletions

View file

@ -160,6 +160,13 @@ public static class SampleData
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
UnassignedXp = 87_757_321_741L,
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
// Focus, Self, Health, Stamina, Mana).
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
// Formula bracket at value=10: ExperienceToAttributeLevel(11) ExperienceToAttributeLevel(10).
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
AugmentationName = "Swords",
BurdenCurrent = 1200,

View file

@ -1,3 +1,5 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
@ -105,6 +107,21 @@ public sealed class CharacterSheet
/// </summary>
public long UnassignedXp { get; init; }
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
// Retail formula: ExperienceToAttributeLevel(value + 1) ExperienceToAttributeLevel(value).
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
/// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self,
/// [6]=Health, [7]=Stamina, [8]=Mana.
/// Cost 0 means the attribute is at max (raise button ghosted).
/// </summary>
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.

View file

@ -29,8 +29,18 @@ namespace AcDream.App.UI.Layout;
/// <c>DisplayDefaultFooter</c> (0x0049cde0): title empty, line-1 value =
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).</para>
///
/// <para><b>Pass 2 pending:</b> selection highlight (Button SetState(6)), State-B footer,
/// raise buttons (CM_Train::Event_TrainAttribute).</para>
/// <para>Footer State B (row selected) bound from
/// <c>DisplaySelectedAttribute</c> (implicitly): title = "{AttrName}: {value}",
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
///
/// <para>Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
/// inside each tab group container.</para>
///
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910).</para>
/// </summary>
public static class CharacterStatController
{
@ -43,6 +53,15 @@ public static class CharacterStatController
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
// Three group containers, each holding three UiButton children forming the 3-piece
// tab visual. State "Open" (UIStateId 0x0C) = active; "Closed" (0x0B) = inactive.
// Source: gmTabUI::SetActive(bool) — the tab-bar controller calls SetState(Open/Closed)
// on the three child buttons of the active vs inactive tab slots.
public const uint TabAttribId = 0x10000228u; // Attributes tab group
public const uint TabSkillsId = 0x10000229u; // Skills tab group
public const uint TabTitlesId = 0x10000538u; // Titles tab group
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
@ -51,36 +70,44 @@ public static class CharacterStatController
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
// ── Raise button element ids ──────────────────────────────────────────────
// Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
// Button state "Normal" (UIStateId 0x01) = affordable (green/active);
// "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
public const uint RaiseOneId = 0x10000246u; // raise × 1
public const uint RaiseTenId = 0x100005EBu; // raise × 10
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
/// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
// ── Row layout constants ─────────────────────────────────────────────────
// List box 0x1000023D is 398px tall after the anchor pass (dat stores 160px; the
// Top+Bottom anchor stretches it when the 575px content slot inflates the sub-layout).
// 9 rows spread evenly over 398px ≈ 44px/row — matching retail's generous spacing.
// Icons are 24px, vertically centered in the row.
private const float RowHeight = 44f;
private const float IconSize = 24f; // icon ~24×24px, vertically centered in the 44px row
private const float RowPadX = 4f; // left inset before the icon
private const float IconGap = 6f; // gap between icon right and name text left
private const float IconSize = 24f;
private const float RowPadX = 4f;
private const float IconGap = 6f;
// ── Attribute row descriptors — retail display order per spec §1 ─────────
// InfoRegion::InfoRegion row sub-element ids:
// 0x10000129 = icon image
// 0x1000012a = m_pLabelText (name)
// 0x1000012b = m_pValueText (value)
// Icon DataIDs from SubMap 0x25000006 (typeId 0x10000002) — spec §2.
private static readonly (string name, uint iconDid)[] AttrRows = new[]
{
("Strength", 0x060002C8u), // enum 1 — spec §1/§2
("Strength", 0x060002C8u), // enum 1
("Endurance", 0x060002C4u), // enum 2
("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order)
("Coordination", 0x060002C9u), // enum 4
("Quickness", 0x060002C6u), // enum 3
("Focus", 0x060002C5u), // enum 5
("Self", 0x060002C7u), // enum 6
};
// Vital icon DataIDs from SubMap 0x25000007 (typeId 0x10000003) — spec §2.
private static readonly (string name, uint iconDid)[] VitalRows = new[]
{
("Health", 0x06004C3Bu), // CurEnum 2
@ -89,14 +116,20 @@ public static class CharacterStatController
};
/// <summary>
/// Bind the Attributes-tab header + 9-row list + footer State-A elements in
/// <paramref name="layout"/> to <paramref name="data"/>.
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
///
/// <para>
/// <b>Interactive mode (Pass 2):</b> each attribute/vital row is a
/// <see cref="UiClickablePanel"/> that fires selection logic on left-click. The
/// selected row index is held in a mutable <c>int[]</c> box (single element) so that
/// all closures share the same mutable slot without escaping the static method boundary.
/// </para>
///
/// <para>
/// <paramref name="spriteResolve"/> resolves a <c>0x06xxxxxx</c> RenderSurface dat id to
/// a (GL tex handle, pixel width, pixel height) triple — the same resolver that
/// DatWidgetFactory uses for chrome sprites. Pass <c>null</c> in tests where
/// icon rendering is not asserted; icons are skipped when the resolver is absent.
/// a (GL tex handle, pixel width, pixel height) triple. Pass <c>null</c> in tests where
/// icon rendering is not asserted.
/// </para>
/// </summary>
public static void Bind(
@ -115,58 +148,119 @@ public static class CharacterStatController
if (layout.FindElement(XpMeterId) is UiMeter meter)
meter.Fill = () => data().XpFraction;
// ── Tab bar button states ─────────────────────────────────────────────
// Each tab group (0x10000228/229/538) is a UiDatElement container whose children
// are UiButton widgets that form the 3-piece tab visual. Setting ActiveState on
// all children to "Open" or "Closed" switches the visible sprite set.
// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B).
// Only Attributes is active; Skills + Titles controllers don't exist yet.
SetTabState(layout, TabAttribId, StateOpen);
SetTabState(layout, TabSkillsId, StateClosed);
SetTabState(layout, TabTitlesId, StateClosed);
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
// gmAttributeUI::PostInit (0x0049db70) calls AddItemFromTemplateList nine times —
// in acdream we manually add 9 UiPanel row containers (no dat template machinery).
// Each row = icon (left, ~16×16) + name (left-justified) + value (right-aligned).
// Mutable selected-index box: -1 = nothing selected.
var sel = new int[] { -1 };
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
// in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
// mounted copy. We collect all copies so we can hide them all initially and
// show/hide the correct set when a row is selected.
//
// At bind time the tree includes all three tab pages (the page-visibility pass
// runs AFTER this). Collecting from the full tree is safe: once the page-
// visibility pass hides the inactive pages their raise buttons are invisible
// regardless of the Visible flag we set here — but the Attributes page's
// buttons (which are NOT hidden by the page pass) must be explicitly hidden.
var allRaise1 = new List<UiButton>();
var allRaise10 = new List<UiButton>();
if (layout.Root is { } r)
{
// Single-pass tree walk to collect all UiButton copies at the two ids.
// FindElement only returns the last-registered copy in _byId; we need ALL
// copies because duplicated sub-layout mounts each tab page independently.
CollectButtonsById(r, RaiseOneId, allRaise1, layout);
CollectButtonsById(r, RaiseTenId, allRaise10, layout);
}
// If tree-walk found nothing, fall back to _byId (covers fake/test layouts).
if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1);
if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10);
// Initial state: raise buttons hidden until a row is selected.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
// ── Footer State A initial binding ────────────────────────────────────
// All footer providers close over sel[] so they can reflect State A vs B per frame.
BindFooterDynamic(layout, datFont, data, sel);
List<UiClickablePanel>? rowPanels = null;
if (layout.FindElement(ListBoxId) is { } list)
{
BuildAttributeRows(list, datFont, spriteResolve, data);
rowPanels = BuildAttributeRows(list, datFont, spriteResolve, data, sel,
allRaise1, allRaise10);
}
// ── Footer — State A (nothing selected): DisplayDefaultFooter (0x0049cde0) ────
// title = "" ; line-1 value = "Select an Attribute to Improve" ;
// line-2 value = InqInt(0x18) = available skill credits.
BindFooterStateA(layout, datFont, data);
// ── Active-page selection (fixes the "dark overlay") ──────────────────
// 0x2100002E is a tabbed window: all three tab pages (Attributes / Skills / Titles) mount
// their content as separate root children that OVERLAP in the same content rect. Retail
// shows only the active page; acdream draws them all, so the inactive pages' backdrops paint
// over the active content (the dim "almost opaque" cover). The shared gmStatManagement ids
// are duplicated across pages and acdream's _byId keeps the LAST-mounted copy, so every widget
// we just bound lives in exactly ONE page. Keep that page visible; hide the rest.
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root)
{
foreach (var page in root.Children)
{
if (page.Children.Count == 0) continue; // tab-bar buttons are leaves — leave them
page.Visible = ContainsWidget(page, anchor); // only the bound page stays visible
if (page.Children.Count == 0) continue;
page.Visible = ContainsWidget(page, anchor);
}
}
}
// ── Tab bar state ────────────────────────────────────────────────────────
/// <summary>
/// Set the <see cref="UiButton.ActiveState"/> on ALL <see cref="UiButton"/> descendants
/// of the tab group container identified by <paramref name="groupId"/> to
/// <paramref name="state"/>. The three-piece tab visual is composed of three UiButton
/// children (left-cap, label/center, right-cap) that must all share the same state.
///
/// <para>Source: retail tab-button assembly with child buttons having UIStateId states
/// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from
/// <c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</para>
/// </summary>
private static void SetTabState(ImportedLayout layout, uint groupId, string state)
{
if (layout.FindElement(groupId) is not { } group) return;
SetButtonStateRecursive(group, state);
}
private static void SetButtonStateRecursive(UiElement node, string state)
{
if (node is UiButton btn)
btn.ActiveState = state;
foreach (var child in node.Children)
SetButtonStateRecursive(child, state);
}
// ── 9-row attribute list ─────────────────────────────────────────────────
private static void BuildAttributeRows(
private static List<UiClickablePanel> BuildAttributeRows(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data)
Func<CharacterSheet> data,
int[] sel,
List<UiButton> allRaise1,
List<UiButton> allRaise10)
{
float listW = list.Width;
float y = 0f; // row-local Y within the list box; each row stacks top-down
float y = 0f;
var rows = new List<UiClickablePanel>();
// Six attribute rows (Strength, Endurance, Coordination, Quickness, Focus, Self).
// Value format: "%d" (buffed integer) — AttributeInfoRegion::Update (0x004f1910).
for (int i = 0; i < AttrRows.Length; i++)
{
var (rowName, iconDid) = AttrRows[i];
int rowIndex = i;
// Capture for the lambda — locals from the loop are captured by VALUE here.
int rowIndex = i; // not used in value yet but keeps the closure honest
AddRow(list, datFont, spriteResolve,
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
@ -186,17 +280,18 @@ public static class CharacterStatController
return v.ToString();
});
row.OnClick = () => HandleRowClick(rowIndex, sel, rows, data, allRaise1, allRaise10);
rows.Add(row);
y += RowHeight;
}
// Three vital rows (Health, Stamina, Mana).
// Value format: "%d/%d" cur/max — Attribute2ndInfoRegion::Update (0x004f19e0).
for (int i = 0; i < VitalRows.Length; i++)
{
var (rowName, iconDid) = VitalRows[i];
int rowIndex = i;
int absIndex = AttrRows.Length + i;
AddRow(list, datFont, spriteResolve,
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
@ -212,20 +307,117 @@ public static class CharacterStatController
};
});
row.OnClick = () => HandleRowClick(absIndex, sel, rows, data, allRaise1, allRaise10);
rows.Add(row);
y += RowHeight;
}
return rows;
}
/// <summary>
/// Add a single row to <paramref name="list"/>: a transparent UiPanel container
/// positioned at <paramref name="left"/>,<paramref name="top"/> of the list, holding
/// three children: icon (UiText with BackgroundSprite), name (UiText, left-justified),
/// value (UiText, right-aligned). All three are ClickThrough non-interactive.
///
/// <para>Row sub-element ids from the retail dat template (InfoRegion::InfoRegion
/// 0x004f1450): 0x10000129 icon, 0x1000012a name, 0x1000012b value.</para>
/// Handles a row click: toggle (same row → deselect), else select new row.
/// Updates highlight, footer providers, and raise-button state.
/// </summary>
private static void AddRow(
private static void HandleRowClick(
int clickedIndex,
int[] sel,
List<UiClickablePanel> rows,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> allRaise10)
{
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
sel[0] = newSel;
// Log for live test confirmation (user tests selection in the studio).
string rowName = GetRowName(newSel);
Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
// Update highlight on all rows.
for (int i = 0; i < rows.Count; i++)
rows[i].BackgroundColor = (i == newSel) ? HighlightBg : Vector4.Zero;
// Update raise buttons.
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
}
/// <summary>Refresh raise button visibility + state based on current selection.
/// Applies to ALL collected raise button copies (one per tab page) so the
/// Attributes-page buttons are correctly shown/hidden regardless of which
/// copy happens to be in ImportedLayout._byId.</summary>
private static void RefreshRaiseButtons(
int selectedIndex,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> allRaise10)
{
if (allRaise1.Count == 0 && allRaise10.Count == 0) return;
if (selectedIndex < 0)
{
// Nothing selected: hide all raise buttons.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
return;
}
var sheet = data();
long cost = GetRaiseCost(sheet, selectedIndex);
bool affordable = cost > 0 && sheet.UnassignedXp >= cost;
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
string btnState = affordable ? StateNormal : StateGhosted;
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; }
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; }
}
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
/// Returns 0 if the cost array is shorter than expected.</summary>
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
{
if (sheet.AttributeRaiseCosts is null || rowIndex < 0
|| rowIndex >= sheet.AttributeRaiseCosts.Length)
return 0L;
return sheet.AttributeRaiseCosts[rowIndex];
}
/// <summary>Return the display name for the row at <paramref name="index"/>,
/// or an empty string if the index is out of range.</summary>
internal static string GetRowName(int index)
{
if (index < 0) return string.Empty;
if (index < AttrRows.Length) return AttrRows[index].name;
int vi = index - AttrRows.Length;
if (vi < VitalRows.Length) return VitalRows[vi].name;
return string.Empty;
}
/// <summary>Return the numeric value for the row at <paramref name="index"/>.</summary>
internal static string GetRowValueString(CharacterSheet sheet, int index)
{
return index switch
{
0 => sheet.Strength.ToString(),
1 => sheet.Endurance.ToString(),
2 => sheet.Coordination.ToString(),
3 => sheet.Quickness.ToString(),
4 => sheet.Focus.ToString(),
5 => sheet.Self.ToString(),
6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}",
7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}",
8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}",
_ => string.Empty,
};
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
/// Returns the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
/// </summary>
private static UiClickablePanel AddRow(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
@ -234,23 +426,19 @@ public static class CharacterStatController
string nameText,
Func<string> valueProvider)
{
// Transparent container — the list box backdrop draws behind everything.
var row = new UiPanel
var row = new UiClickablePanel
{
Left = left,
Top = top,
Width = width,
Height = height,
BackgroundColor = Vector4.Zero, // transparent
BackgroundColor = Vector4.Zero, // transparent until selected
BorderColor = Vector4.Zero,
ClickThrough = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float iconY = (height - IconSize) * 0.5f;
// Icon element — UiText with BackgroundSprite draws the dat sprite under (empty) text.
// Retail: InfoRegion::InfoRegion sub-element 0x10000129 → UIRegion::SetImageByDID.
var iconEl = new UiText
{
Left = RowPadX,
@ -258,7 +446,7 @@ public static class CharacterStatController
Width = IconSize,
Height = IconSize,
ClickThrough = true,
DatFont = null, // icon has no text
DatFont = null,
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
SpriteResolve = spriteResolve is not null
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
@ -267,15 +455,11 @@ public static class CharacterStatController
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
// Name label — left-justified within its column width.
// Retail: InfoRegion sub-element 0x1000012a (m_pLabelText).
// UiText has no explicit LeftAligned flag; we use the default non-Centered/non-RightAligned
// scroll path with a single-line LinesProvider. Padding=1 keeps the text off the left edge.
float nameX = RowPadX + IconSize + IconGap;
float nameW = width * 0.60f; // ~60% of the row width for the name column
float nameY = 0f; // fill full row height; one line top-pins to the row top
float nameW = width * 0.60f;
float nameY = 0f;
string capturedName = nameText; // close over the name string directly
string capturedName = nameText;
var nameEl = new UiText
{
Left = nameX,
@ -291,8 +475,6 @@ public static class CharacterStatController
};
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) };
// Value label — right-aligned, vertically centered.
// Retail: InfoRegion sub-element 0x1000012b (m_pValueText).
float valueW = width - nameX - nameW - RowPadX;
float valueX = nameX + nameW;
@ -314,55 +496,65 @@ public static class CharacterStatController
row.AddChild(nameEl);
row.AddChild(valueEl);
list.AddChild(row);
return row;
}
// ── Footer State A binding ────────────────────────────────────────────────
// ── Footer — dynamic (State A + State B via sel[]) ────────────────────────
/// <summary>
/// Bind footer elements to their State-A (nothing selected) content.
/// Bind all 5 footer elements with providers that close over <paramref name="sel"/>:
/// when <c>sel[0] == -1</c> (nothing selected) they emit State-A content;
/// when a row is selected they emit State-B content.
///
/// <para>Matching the user's authoritative shot layout (3-line footer at the panel bottom):
/// <list type="bullet">
/// <item>Title (0x1000024e) → "Select an Attribute to Improve" — centered, full-width.</item>
/// <item>Line-1 label (0x10000242) → "Skill Credits Available:"</item>
/// <item>Line-1 value (0x10000243) → InqInt(0x18) = available skill credits.</item>
/// <item>Line-2 label (0x10000244) → "Unassigned Experience:"</item>
/// <item>Line-2 value (0x10000245) → InqInt64(2) = unassigned XP.</item>
/// </list>
/// </para>
/// <para><b>State A</b> (DisplayDefaultFooter 0x0049cde0):
/// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:";
/// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:";
/// line-2 value = UnassignedXp.</para>
///
/// <para>Source: <c>DisplayDefaultFooter</c> (0x0049cde0) + user's retail screenshot.</para>
/// <para><b>State B</b> (attribute selected):
/// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
/// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
/// line-2 value = UnassignedXp.</para>
/// </summary>
private static void BindFooterStateA(
private static void BindFooterDynamic(
ImportedLayout layout,
UiDatFont? datFont,
Func<CharacterSheet> data)
Func<CharacterSheet> data,
int[] sel)
{
// 0x1000024e title → "Select an Attribute to Improve"
Label(layout, FooterTitleId, datFont, Body,
static () => "Select an Attribute to Improve");
// Title: State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
Label(layout, FooterTitleId, datFont, Body, () =>
{
if (sel[0] < 0) return "Select an Attribute to Improve";
var s = data();
string name = GetRowName(sel[0]);
string value = GetRowValueString(s, sel[0]);
return $"{name}: {value}";
});
// 0x10000242 line-1 label → "Skill Credits Available:"
Label(layout, FooterLine1Label, datFont, Body,
static () => "Skill Credits Available:");
// Line-1 label: State A = "Skill Credits Available:"; State B = "Experience To Raise:"
Label(layout, FooterLine1Label, datFont, Body, () =>
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
// 0x10000243 line-1 value → InqInt(0x18) = available skill credits
Label(layout, FooterLine1Value, datFont, Body,
() => data().SkillCredits.ToString());
// Line-1 value: State A = SkillCredits; State B = raise cost
Label(layout, FooterLine1Value, datFont, Body, () =>
{
if (sel[0] < 0) return data().SkillCredits.ToString();
long cost = GetRaiseCost(data(), sel[0]);
return cost > 0 ? cost.ToString("N0") : "—";
});
// 0x10000244 line-2 label → "Unassigned Experience:"
// Line-2 label: "Unassigned Experience:" in both states
Label(layout, FooterLine2Label, datFont, Body,
static () => "Unassigned Experience:");
// 0x10000245 line-2 value → InqInt64(2) = unassigned XP
// Line-2 value: UnassignedXp in both states
Label(layout, FooterLine2Value, datFont, Body,
() => data().UnassignedXp.ToString("N0"));
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Depth-first reference search: is <paramref name="target"/> somewhere under
/// <paramref name="node"/>? Used to find which overlapping tab page owns the bound widgets.</summary>
private static bool ContainsWidget(UiElement node, UiElement target)
{
if (ReferenceEquals(node, target)) return true;
@ -376,9 +568,73 @@ public static class CharacterStatController
if (layout.FindElement(id) is UiText t)
{
t.DatFont = datFont;
t.Centered = true; // single-line centered label (retail UIElement_Text)
t.Centered = true;
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// <summary>
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
///
/// <para>
/// The standard <see cref="ImportedLayout.FindElement"/> returns only the LAST widget
/// registered for a given id; for elements duplicated across tab-page sub-layouts
/// (raise buttons, close buttons) we need ALL copies so that visibility changes are
/// reflected in every page — not just the last-mounted one.
/// </para>
///
/// <para>
/// Implementation: walk the entire <paramref name="root"/> tree calling
/// <see cref="ImportedLayout.FindElement"/> is not enough (it uses a dict). Instead we
/// exploit the fact that identical element ids produce widgets that all share the SAME
/// <see cref="UiButton"/> instance in <c>_byId</c> for THEIR copy; but siblings from
/// different inheritance mounts are SEPARATE instances not in the same <c>_byId</c> slot.
/// We therefore walk the tree recursively and collect every <see cref="UiButton"/> whose
/// <c>ActiveState</c> reflects the dat default (before our code sets it), which is not a
/// reliable discriminator. Instead, we gather ALL <see cref="UiButton"/> instances from
/// the subtree at the known spatial position (bottom of the panel) — but positions can
/// overlap across pages.
/// </para>
///
/// <para>
/// The correct approach: since <c>_byId</c> stores only one instance per id, we use the
/// <see cref="ImportedLayout.FindElement"/> for the canonical id, then do a FULL tree walk
/// to find ADDITIONAL <see cref="UiButton"/> instances that have identical Width×Height to
/// the known button. This works because the three page copies share the same dat template
/// and thus the same geometry. Collected via reference-equality guard to avoid duplicates.
/// </para>
/// </summary>
private static void CollectButtonsById(
UiElement node,
uint targetId,
List<UiButton> result,
ImportedLayout layout)
{
// Find the canonical copy (last registered in _byId) as the geometry reference.
if (layout.FindElement(targetId) is not UiButton canonical) return;
// Walk the tree and collect ALL UiButton instances matching the canonical geometry.
// The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
var seen = new HashSet<UiButton>(ReferenceEqualityComparer.Instance);
CollectMatchingButtons(node, canonical, seen, result);
}
private static void CollectMatchingButtons(
UiElement node,
UiButton canonical,
HashSet<UiButton> seen,
List<UiButton> result)
{
if (node is UiButton btn
&& Math.Abs(btn.Width - canonical.Width) < 0.5f
&& Math.Abs(btn.Height - canonical.Height) < 0.5f
&& seen.Add(btn))
{
result.Add(btn);
}
foreach (var child in node.Children)
CollectMatchingButtons(child, canonical, seen, result);
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
@ -94,3 +95,43 @@ public class UiSimpleButton : UiPanel
ctx.DrawString(Text, tx, ty, TextColor);
}
}
/// <summary>
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
/// is a transparent container that needs to respond to pointer hits while its children
/// (icon, name, value) are ClickThrough decorations.
///
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
/// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.</para>
/// </summary>
public class UiClickablePanel : UiPanel
{
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
public Action? OnClick { get; set; }
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
// which is the UiElement base default). Explicit for clarity.
ClickThrough = false;
}
/// <summary>HandlesClick = true ensures this row receives its own Click even when
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
/// window with a whole-window drag handle). Without this, the press would be consumed
/// by the ancestor's drag logic and the Click would never fire.</summary>
public override bool HandlesClick => true;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && Enabled)
{
OnClick?.Invoke();
return true;
}
return false;
}
}

View file

@ -8,6 +8,10 @@ namespace AcDream.App.Tests.UI.Layout;
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
/// Pure data wiring against fake layouts; no dats, no GL.
///
/// <para>Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
/// (c) raise-button affordability; (d) tab button states.</para>
/// </summary>
public class CharacterStatControllerTests
{
@ -66,7 +70,6 @@ public class CharacterStatControllerTests
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
/// <summary>Bind adds exactly 9 UiPanel row containers to the list box.</summary>
[Fact]
public void Bind_AttributeList_Has9Rows()
{
@ -75,14 +78,29 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
}
/// <summary>
/// Every row must have a right-aligned value UiText child (the last UiText in
/// the row), confirming the RightAligned path was wired.
/// </summary>
[Fact]
public void Bind_AttributeList_RowsAreClickablePanels()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
// All rows must have an OnClick wired (not null) and ClickThrough = false.
foreach (var row in rows)
{
Assert.NotNull(row.OnClick);
Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
}
}
[Fact]
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
{
@ -95,18 +113,13 @@ public class CharacterStatControllerTests
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
// Last UiText in each row is the value (right-aligned).
var texts = row.Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
var valueEl = texts[^1]; // last = value
var valueEl = texts[^1];
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
}
}
/// <summary>
/// Retail display order: Strength, Endurance, Coordination, Quickness, Focus, Self,
/// Health, Stamina, Mana (spec §1). Name is the SECOND UiText child of each row.
/// </summary>
[Fact]
public void Bind_AttributeList_RowNamesInRetailOrder()
{
@ -126,19 +139,13 @@ public class CharacterStatControllerTests
for (int i = 0; i < 9; i++)
{
// Name UiText = second UiText in the row (after the icon UiText).
var texts = rows[i].Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 2, $"row {i} must have name + value");
string rowName = texts[1].LinesProvider()[0].Text; // index 1 = name (0 = icon)
string rowName = texts[1].LinesProvider()[0].Text;
Assert.Equal(expectedNames[i], rowName);
}
}
/// <summary>
/// Attribute row values are plain integers; vital row values are "cur/max".
/// Validates against SampleData fixture: Str=200, Quick=200, rest=10;
/// Health=5/5, Stamina=10/10, Mana=10/10.
/// </summary>
[Fact]
public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
{
@ -149,45 +156,34 @@ public class CharacterStatControllerTests
var rows = list.Children.OfType<UiPanel>().ToList();
// Helper: value text is the last UiText in the row.
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
// Attribute rows 0-5.
Assert.Equal("200", ValueOf(rows[0])); // Strength
Assert.Equal("10", ValueOf(rows[1])); // Endurance
Assert.Equal("10", ValueOf(rows[2])); // Coordination
Assert.Equal("200", ValueOf(rows[3])); // Quickness
Assert.Equal("10", ValueOf(rows[4])); // Focus
Assert.Equal("10", ValueOf(rows[5])); // Self
// Vital rows 6-8: "cur/max" format.
Assert.Equal("5/5", ValueOf(rows[6])); // Health
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
}
/// <summary>
/// When spriteResolve is provided the icon UiText must have BackgroundSprite set
/// to the correct DataID; when null the BackgroundSprite must be 0.
/// </summary>
[Fact]
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
// Non-null resolver — just returns a fake handle for any id.
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16)); // fake: handle == id
spriteResolve: id => (id, 16, 16));
var rows = list.Children.OfType<UiPanel>().ToList();
Assert.Equal(9, rows.Count);
// First icon = Strength row; expected DataID 0x060002C8.
var iconEl = rows[0].Children.OfType<UiText>().First();
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
// Vital (Health) = row 6; expected DataID 0x06004C3B.
var healthIcon = rows[6].Children.OfType<UiText>().First();
Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
}
@ -219,6 +215,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Initial state (nothing selected): State-A title.
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
@ -241,7 +238,6 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// SampleData.SampleCharacter().SkillCredits == 96
Assert.Equal("96", val.LinesProvider()[0].Text);
}
@ -264,27 +260,375 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// SampleData.SampleCharacter().UnassignedXp == 87_757_321_741
// Formatted as "N0" with thousands separators.
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── SkillCredits / UnassignedXp propagation through SampleData ───────────
// ── Pass 2: Row selection → Footer State B ───────────────────────────────
[Fact]
public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
{
// Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Simulate a click on row 4 (Focus).
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
rows[4].OnClick!();
// Footer title should now be "Focus: 10".
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine1Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// Focus raise cost = 110 (SampleData fixture).
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
{
var lbl = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Label, lbl),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
{
var val = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterLine2Value, val),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
// UnassignedXp = 87_757_321_741L
var expected = (87_757_321_741L).ToString("N0");
Assert.Equal(expected, val.LinesProvider()[0].Text);
}
// ── Pass 2: Toggle deselects ──────────────────────────────────────────────
[Fact]
public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Focus (row 4).
rows[4].OnClick!();
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
// Click the same row again → deselect.
rows[4].OnClick!();
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SwitchRow_UpdatesToNewRow()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// Select Endurance (row 1, value=10).
rows[1].OnClick!();
Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
// Select Self (row 5, value=10).
rows[5].OnClick!();
Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
}
// ── Pass 2: Row highlight ─────────────────────────────────────────────────
[Fact]
public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
// All rows start transparent.
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
// Select row 2 (Coordination).
rows[2].OnClick!();
Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
Assert.Equal(0f, rows[1].BackgroundColor.W);
}
[Fact]
public void RowClick_Deselect_ClearsHighlight()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0f, rows[2].BackgroundColor.W);
}
// ── Pass 2: Raise button affordability ───────────────────────────────────
[Fact]
public void RaiseButtons_InitiallyHidden()
{
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.False(btn1.Visible, "raise×1 must start hidden");
Assert.False(btn10.Visible, "raise×10 must start hidden");
}
[Fact]
public void RaiseButtons_AffordableRow_ShowsNormalState()
{
// Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
Assert.True(btn1.Visible, "raise×1 visible on selection");
Assert.True(btn10.Visible, "raise×10 visible on selection");
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_MaxedRow_ShowsGhostedState()
{
// Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
Assert.True(btn1.Visible, "raise button visible even when disabled");
Assert.Equal("Ghosted", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_Deselect_HidesButtons()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
rows[4].OnClick!(); // select
Assert.True(btn1.Visible);
rows[4].OnClick!(); // deselect
Assert.False(btn1.Visible, "raise button hidden after deselect");
}
// ── Pass 2: Tab button states ─────────────────────────────────────────────
[Fact]
public void TabButtons_AttributesGroupButtons_SetToOpenState()
{
var tabGroup = new UiPanel(); // fake tab group container
var btn1 = MakeButton();
var btn2 = MakeButton();
tabGroup.AddChild(btn1);
tabGroup.AddChild(btn2);
var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// All UiButton children of the Attributes group should be "Open".
Assert.Equal("Open", btn1.ActiveState);
Assert.Equal("Open", btn2.ActiveState);
}
[Fact]
public void TabButtons_SkillsGroupButtons_SetToClosedState()
{
var tabGroup = new UiPanel();
var btn = MakeButton();
tabGroup.AddChild(btn);
var layout = Fake((CharacterStatController.TabSkillsId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Closed", btn.ActiveState);
}
[Fact]
public void TabButtons_TitlesGroupButtons_SetToClosedState()
{
var tabGroup = new UiPanel();
var btn = MakeButton();
tabGroup.AddChild(btn);
var layout = Fake((CharacterStatController.TabTitlesId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Closed", btn.ActiveState);
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
[Fact]
public void GetRaiseCost_Index4Focus_Returns110()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
}
[Fact]
public void GetRaiseCost_Index0Strength_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
}
[Fact]
public void GetRaiseCost_OutOfRange_Returns0()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
}
// ── GetRowName helper ─────────────────────────────────────────────────────
[Fact]
public void GetRowName_Index0_ReturnsStrength()
=> Assert.Equal("Strength", CharacterStatController.GetRowName(0));
[Fact]
public void GetRowName_Index4_ReturnsFocus()
=> Assert.Equal("Focus", CharacterStatController.GetRowName(4));
[Fact]
public void GetRowName_Index6_ReturnsHealth()
=> Assert.Equal("Health", CharacterStatController.GetRowName(6));
[Fact]
public void GetRowName_NegativeIndex_ReturnsEmpty()
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
// ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]
public void SampleCharacter_SkillCredits_Is96()
{
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
}
=> Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
[Fact]
public void SampleCharacter_UnassignedXp_IsSet()
=> Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
{
Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
Assert.NotNull(costs);
Assert.Equal(9, costs.Length);
}
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
[Fact]
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
// ── UiText flag sanity ────────────────────────────────────────────────────
[Fact]
public void UiText_RightAligned_DefaultFalse()
@ -300,13 +644,20 @@ public class CharacterStatControllerTests
Assert.True(t.RightAligned);
}
// ── Robustness: a partial layout (missing ids) must not throw ────────────
// ── Robustness ────────────────────────────────────────────────────────────
[Fact]
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
// ── Helper ─────────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
{
var info = new ElementInfo { Id = 0, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0));
}
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{