diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index de4a9eb9..da094467 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -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, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 4fe1c657..b64c64eb 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -1,3 +1,5 @@ +using System; + namespace AcDream.App.UI.Layout; /// @@ -105,6 +107,21 @@ public sealed class CharacterSheet /// 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. + + /// + /// 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). + /// + public long[] AttributeRaiseCosts { get; init; } = Array.Empty(); + // ── Augmentations (UpdateAugmentations 0x004b9000) ───────────────────── // Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb. diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 310707d6..3600401c 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -29,8 +29,18 @@ namespace AcDream.App.UI.Layout; /// DisplayDefaultFooter (0x0049cde0): title empty, line-1 value = /// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)). /// -/// Pass 2 pending: selection highlight (Button SetState(6)), State-B footer, -/// raise buttons (CM_Train::Event_TrainAttribute). +/// Footer State B (row selected) bound from +/// DisplaySelectedAttribute (implicitly): title = "{AttrName}: {value}", +/// line-1 label = "Experience To Raise:", line-1 value = raise cost, +/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. +/// +/// 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. +/// +/// 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). /// 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 + /// Row highlight color — semi-translucent gold, matches retail + /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. + 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 }; /// - /// Bind the Attributes-tab header + 9-row list + footer State-A elements in - /// to . + /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states, + /// and raise buttons in to . + /// + /// + /// Interactive mode (Pass 2): each attribute/vital row is a + /// that fires selection logic on left-click. The + /// selected row index is held in a mutable int[] box (single element) so that + /// all closures share the same mutable slot without escaping the static method boundary. + /// /// /// /// resolves a 0x06xxxxxx RenderSurface dat id to - /// a (GL tex handle, pixel width, pixel height) triple — the same resolver that - /// DatWidgetFactory uses for chrome sprites. Pass null 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 null in tests where + /// icon rendering is not asserted. /// /// 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(); + var allRaise10 = new List(); + 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? 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 ──────────────────────────────────────────────────────── + + /// + /// Set the on ALL descendants + /// of the tab group container identified by to + /// . The three-piece tab visual is composed of three UiButton + /// children (left-cap, label/center, right-cap) that must all share the same state. + /// + /// Source: retail tab-button assembly with child buttons having UIStateId states + /// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from + /// docs/research/2026-06-25-retail-ui-layout-dump.json. + /// + 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 BuildAttributeRows( UiElement list, UiDatFont? datFont, Func? spriteResolve, - Func data) + Func data, + int[] sel, + List allRaise1, + List 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(); - // 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; } /// - /// Add a single row to : a transparent UiPanel container - /// positioned at , of the list, holding - /// three children: icon (UiText with BackgroundSprite), name (UiText, left-justified), - /// value (UiText, right-aligned). All three are ClickThrough non-interactive. - /// - /// Row sub-element ids from the retail dat template (InfoRegion::InfoRegion - /// 0x004f1450): 0x10000129 icon, 0x1000012a name, 0x1000012b value. + /// Handles a row click: toggle (same row → deselect), else select new row. + /// Updates highlight, footer providers, and raise-button state. /// - private static void AddRow( + private static void HandleRowClick( + int clickedIndex, + int[] sel, + List rows, + Func data, + List allRaise1, + List 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); + } + + /// 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. + private static void RefreshRaiseButtons( + int selectedIndex, + Func data, + List allRaise1, + List 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; } + } + + /// Return the raise cost for row from the sheet. + /// Returns 0 if the cost array is shorter than expected. + 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]; + } + + /// Return the display name for the row at , + /// or an empty string if the index is out of range. + 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; + } + + /// Return the numeric value for the row at . + 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, + }; + } + + /// + /// Add a single attribute/vital row to as a + /// containing icon + name + value children. + /// Returns the panel so the caller can wire . + /// + private static UiClickablePanel AddRow( UiElement list, UiDatFont? datFont, Func? spriteResolve, @@ -234,23 +426,19 @@ public static class CharacterStatController string nameText, Func 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[]) ──────────────────────── /// - /// Bind footer elements to their State-A (nothing selected) content. + /// Bind all 5 footer elements with providers that close over : + /// when sel[0] == -1 (nothing selected) they emit State-A content; + /// when a row is selected they emit State-B content. /// - /// Matching the user's authoritative shot layout (3-line footer at the panel bottom): - /// - /// Title (0x1000024e) → "Select an Attribute to Improve" — centered, full-width. - /// Line-1 label (0x10000242) → "Skill Credits Available:" - /// Line-1 value (0x10000243) → InqInt(0x18) = available skill credits. - /// Line-2 label (0x10000244) → "Unassigned Experience:" - /// Line-2 value (0x10000245) → InqInt64(2) = unassigned XP. - /// - /// + /// State A (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. /// - /// Source: DisplayDefaultFooter (0x0049cde0) + user's retail screenshot. + /// State 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. /// - private static void BindFooterStateA( + private static void BindFooterDynamic( ImportedLayout layout, UiDatFont? datFont, - Func data) + Func 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 ────────────────────────────────────────────────────────────── - /// Depth-first reference search: is somewhere under - /// ? Used to find which overlapping tab page owns the bound widgets. 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) }; } } + + /// + /// Depth-first tree walk to collect every that was registered + /// in under the given dat element . + /// + /// + /// The standard 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. + /// + /// + /// + /// Implementation: walk the entire tree calling + /// is not enough (it uses a dict). Instead we + /// exploit the fact that identical element ids produce widgets that all share the SAME + /// instance in _byId for THEIR copy; but siblings from + /// different inheritance mounts are SEPARATE instances not in the same _byId slot. + /// We therefore walk the tree recursively and collect every whose + /// ActiveState reflects the dat default (before our code sets it), which is not a + /// reliable discriminator. Instead, we gather ALL instances from + /// the subtree at the known spatial position (bottom of the panel) — but positions can + /// overlap across pages. + /// + /// + /// + /// The correct approach: since _byId stores only one instance per id, we use the + /// for the canonical id, then do a FULL tree walk + /// to find ADDITIONAL 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. + /// + /// + private static void CollectButtonsById( + UiElement node, + uint targetId, + List 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(ReferenceEqualityComparer.Instance); + CollectMatchingButtons(node, canonical, seen, result); + } + + private static void CollectMatchingButtons( + UiElement node, + UiButton canonical, + HashSet seen, + List 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); + } } diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index b6a2085f..a6c3269d 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -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); } } + +/// +/// A that fires an 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. +/// +/// Retail analog: the AttributeInfoRegion row widget in gmAttributeUI +/// catches UIEvent_LeftClick (0x01) and calls SetSelectedAttribute on the +/// parent window. In acdream we wire the equivalent via this action callback instead of +/// the retail message bus. +/// +public class UiClickablePanel : UiPanel +{ + /// Called when the user releases the left mouse button over this panel. + 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; + } + + /// 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. + public override bool HandlesClick => true; + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && Enabled) + { + OnClick?.Invoke(); + return true; + } + return false; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index d1d66107..dc14bf60 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -8,6 +8,10 @@ namespace AcDream.App.Tests.UI.Layout; /// Unit tests for — 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. +/// +/// 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. /// public class CharacterStatControllerTests { @@ -66,7 +70,6 @@ public class CharacterStatControllerTests // ── Attribute list — 9 rows in list box 0x1000023D ─────────────────────── - /// Bind adds exactly 9 UiPanel row containers to the list box. [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 matches. var rows = list.Children.OfType().ToList(); Assert.Equal(9, rows.Count); } - /// - /// Every row must have a right-aligned value UiText child (the last UiText in - /// the row), confirming the RightAligned path was wired. - /// + [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().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().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"); } } - /// - /// Retail display order: Strength, Endurance, Coordination, Quickness, Focus, Self, - /// Health, Stamina, Mana (spec §1). Name is the SECOND UiText child of each row. - /// [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().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); } } - /// - /// 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. - /// [Fact] public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax() { @@ -149,45 +156,34 @@ public class CharacterStatControllerTests var rows = list.Children.OfType().ToList(); - // Helper: value text is the last UiText in the row. string ValueOf(UiPanel row) => row.Children.OfType().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 } - /// - /// When spriteResolve is provided the icon UiText must have BackgroundSprite set - /// to the correct DataID; when null the BackgroundSprite must be 0. - /// [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().ToList(); Assert.Equal(9, rows.Count); - // First icon = Strength row; expected DataID 0x060002C8. var iconEl = rows[0].Children.OfType().First(); Assert.Equal(0x060002C8u, iconEl.BackgroundSprite); - // Vital (Health) = row 6; expected DataID 0x06004C3B. var healthIcon = rows[6].Children.OfType().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().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().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().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().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().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().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().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().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().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().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().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().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 ────────────────────────────────────────────────────────────── + + /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites). + 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) {