From 902160098ae0305166814fc6984fbb510e82d18f Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 25 Jun 2026 20:36:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20Character=20Attributes=20tab=20?= =?UTF-8?q?=E2=80=94=209-row=20list=20(icons=20+=20values=20+=20vitals)=20?= =?UTF-8?q?+=20footer=20State-A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 1 of the Attributes tab interactive controller. Replaces the placeholder UiText attribute list with 9 real manual-layout rows matching retail's gmAttributeUI::PostInit (0x0049db70) structure: - 6 attribute rows (Strength/Endurance/Coordination/Quickness/Focus/Self) in retail display order (Coord enum 4 before Quick enum 3 — spec §1) - 3 vital rows (Health/Stamina/Mana) with cur/max format per Attribute2ndInfoRegion::Update (0x004f19e0) - Each row: icon (UiText.BackgroundSprite = 0x06xxxxxx RenderSurface via spriteResolve), name (left-justified), value (new RightAligned mode) Icon DataIDs from SubMap 0x25000006/0x25000007 via spec §2: STR 0x060002C8, END 0x060002C4, COORD 0x060002C9, QUICK 0x060002C6, FOCUS 0x060002C5, SELF 0x060002C7, HP 0x06004C3B, SP 0x06004C3C, MP 0x06004C3D Footer State-A (DisplayDefaultFooter 0x0049cde0): 0x1000024e title = "", 0x10000243 line-1-value = "Select an Attribute to Improve", 0x10000245 line-2-value = SkillCredits (InqInt(0x18)) Other changes: - UiText: add RightAligned bool (single-line right-justified, mirrors Centered path) - CharacterSheet: add SkillCredits property (retail InqInt(0x18)) - SampleData: update to spec fixture values (Str/Quick=200, rest=10, SkillCredits=96, Health=5/5, Stamina=10/10, Mana=10/10) - FixtureProvider: pass stack.ResolveChrome as spriteResolve for icon rendering - Tests: 13 targeted tests (9-row count, row order, attr+vital values, icon DataIDs, RightAligned flag, footer State-A all 5 elements) Pass 2 (selection/raise buttons) is separate per spec. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Studio/FixtureProvider.cs | 9 +- src/AcDream.App/Studio/SampleData.cs | 23 +- src/AcDream.App/UI/Layout/CharacterSheet.cs | 8 + .../UI/Layout/CharacterStatController.cs | 324 +++++++++++++++--- src/AcDream.App/UI/UiText.cs | 29 ++ .../UI/Layout/CharacterStatControllerTests.cs | 233 ++++++++++++- 6 files changed, 552 insertions(+), 74 deletions(-) diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index e35be76c..bf0e6fbd 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -116,12 +116,13 @@ public static class FixtureProvider case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E) // Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/ - // total-XP/XP-meter + the attribute list). NOT the text-report sub-panel - // (that is gmCharacterInfoUI 0x2100001A → CharacterController). + // total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report + // sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController). CharacterStatController.Bind( layout, - data: SampleData.SampleCharacter, - datFont: stack.VitalsDatFont); + data: SampleData.SampleCharacter, + datFont: stack.VitalsDatFont, + spriteResolve: stack.ResolveChrome); break; default: diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index 969af65c..0877285f 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -138,20 +138,25 @@ public static class SampleData XpToNextLevel = 42_000_000, XpFraction = 0.63f, - HealthCurrent = 80, HealthMax = 100, - StaminaCurrent = 60, StaminaMax = 100, - ManaCurrent = 90, ManaMax = 100, + // Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal). + HealthCurrent = 5, HealthMax = 5, + StaminaCurrent = 10, StaminaMax = 10, + ManaCurrent = 10, ManaMax = 10, - Strength = 100, - Endurance = 100, - Quickness = 100, - Coordination = 100, - Focus = 100, - Self = 100, + // Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal). + Strength = 200, + Endurance = 10, + Quickness = 200, + Coordination = 10, + Focus = 10, + Self = 10, UnspentSkillCredits = 12, SpecializedSkillCredits = 4, + // Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A. + SkillCredits = 96, + AugmentationName = "Swords", BurdenCurrent = 1200, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index aeeec885..a562fd47 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -86,10 +86,18 @@ public sealed class CharacterSheet // ── Skills (UpdateFakeSkills 0x004b8930) ──────────────────────────────── // Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits. + // InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab. public int UnspentSkillCredits { get; init; } public int SpecializedSkillCredits { get; init; } + /// + /// Available (unspent) skill credits shown in the Attributes tab footer State-A. + /// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0). + /// Element 0x10000245 (footer line-2 value). + /// + public int SkillCredits { get; init; } + // ── 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 fccf8508..d507af08 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -19,12 +19,18 @@ namespace AcDream.App.UI.Layout; /// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) + /// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage, /// PK status, level, total XP and the XP-to-level meter. The attribute list is the -/// gmAttributeUI list box (0x1000023D). +/// gmAttributeUI list box (0x1000023D), built as 9 manual-layout rows per +/// gmAttributeUI::PostInit (0x0049db70) + AttributeInfoRegion / Attribute2ndInfoRegion +/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail +/// dat template (each icon is a 0x06xxxxxx RenderSurface DataID from SubMap +/// 0x25000006 / 0x25000007, spec §2). /// -/// Refinement pending: exact label wording comes from the dat StringTable -/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical -/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row -/// template) are a later pass; this binds values into the real element rects first. +/// Footer State A (nothing selected) bound from +/// 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). /// public static class CharacterStatController { @@ -37,16 +43,64 @@ public static class CharacterStatController public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter) public const uint ListBoxId = 0x1000023Du; // m_pListBox container + // ── Footer element ids (gmStatManagementUI struct fields) ──────────────── + // Source: acclient.h / DisplayDefaultFooter (0x0049cde0) + public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel + public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel + public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue + public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel + public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue + 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 layout constants ───────────────────────────────────────────────── + // Row height matches retail's UIElement_ListBox template (~20px; spec §1 "each ~20px tall"). + private const float RowHeight = 20f; + private const float IconSize = 16f; // icon element is ~16×16px (spec §Goal) + private const float RowPadX = 2f; // left inset before the icon + private const float IconGap = 4f; // gap between icon right and name text left + + // ── 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 + ("Endurance", 0x060002C4u), // enum 2 + ("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order) + ("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 + ("Stamina", 0x06004C3Cu), // CurEnum 4 + ("Mana", 0x06004C3Du), // CurEnum 6 + }; + /// - /// Bind the Attributes-tab header + list elements in to - /// . Each element is the real importer-mounted widget; the - /// providers are polled each frame so a live session or the Studio can swap the sheet - /// without re-binding. + /// Bind the Attributes-tab header + 9-row list + footer State-A elements in + /// to . + /// + /// + /// 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. + /// /// - public static void Bind(ImportedLayout layout, Func data, UiDatFont? datFont = null) + public static void Bind( + ImportedLayout layout, + Func data, + UiDatFont? datFont = null, + Func? spriteResolve = null) { Label(layout, NameId, datFont, Gold, () => data().Name); Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty); @@ -58,25 +112,20 @@ public static class CharacterStatController if (layout.FindElement(XpMeterId) is UiMeter meter) meter.Fill = () => data().XpFraction; - // Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic - // container in the import, so attach a single text view sized to the rows and pinned - // to the top of the list region. (The full per-row AttributeInfoRegion widget is a - // later pass; this renders the six innate attributes + values faithfully.) + // ── 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). if (layout.FindElement(ListBoxId) is { } list) { - var rows = new UiText - { - DatFont = datFont, - ClickThrough = true, - Left = 12f, - Top = 10f, - Width = MathF.Max(60f, list.Width - 24f), - Height = 9f * LineHeight, // sized to the 8 content lines so it top-aligns - }; - rows.LinesProvider = () => AttributeRows(data()); - list.AddChild(rows); + BuildAttributeRows(list, datFont, spriteResolve, data); } + // ── 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 @@ -94,6 +143,209 @@ public static class CharacterStatController } } + // ── 9-row attribute list ───────────────────────────────────────────────── + + private static void BuildAttributeRows( + UiElement list, + UiDatFont? datFont, + Func? spriteResolve, + Func data) + { + float listW = list.Width; + float y = 0f; // row-local Y within the list box; each row stacks top-down + + // 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]; + + // 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, + left: 0f, top: y, width: listW, height: RowHeight, + iconDid: iconDid, + nameText: rowName, + valueProvider: () => + { + var s = data(); + int v = rowIndex switch + { + 0 => s.Strength, + 1 => s.Endurance, + 2 => s.Coordination, + 3 => s.Quickness, + 4 => s.Focus, + 5 => s.Self, + _ => 0, + }; + return v.ToString(); + }); + + 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; + + AddRow(list, datFont, spriteResolve, + left: 0f, top: y, width: listW, height: RowHeight, + iconDid: iconDid, + nameText: rowName, + valueProvider: () => + { + var s = data(); + return rowIndex switch + { + 0 => $"{s.HealthCurrent}/{s.HealthMax}", + 1 => $"{s.StaminaCurrent}/{s.StaminaMax}", + 2 => $"{s.ManaCurrent}/{s.ManaMax}", + _ => string.Empty, + }; + }); + + y += RowHeight; + } + } + + /// + /// 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. + /// + private static void AddRow( + UiElement list, + UiDatFont? datFont, + Func? spriteResolve, + float left, float top, float width, float height, + uint iconDid, + string nameText, + Func valueProvider) + { + // Transparent container — the list box backdrop draws behind everything. + var row = new UiPanel + { + Left = left, + Top = top, + Width = width, + Height = height, + BackgroundColor = Vector4.Zero, // transparent + 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, + Top = iconY, + Width = IconSize, + Height = IconSize, + ClickThrough = true, + DatFont = null, // icon has no text + BackgroundSprite = spriteResolve is not null ? iconDid : 0u, + SpriteResolve = spriteResolve is not null + ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } + : null, + LinesProvider = static () => Array.Empty(), + 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 + + string capturedName = nameText; // close over the name string directly + var nameEl = new UiText + { + Left = nameX, + Top = nameY, + Width = nameW, + Height = height, + DatFont = datFont, + ClickThrough = true, + Centered = false, + RightAligned = false, + Padding = 1f, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + 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; + + var valueEl = new UiText + { + Left = valueX, + Top = nameY, + Width = valueW > 0f ? valueW : 40f, + Height = height, + DatFont = datFont, + ClickThrough = true, + RightAligned = true, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + var capturedProvider = valueProvider; + valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) }; + + row.AddChild(iconEl); + row.AddChild(nameEl); + row.AddChild(valueEl); + list.AddChild(row); + } + + // ── Footer State A binding ──────────────────────────────────────────────── + + /// + /// Bind footer elements to their State-A (nothing selected) content per + /// DisplayDefaultFooter (0x0049cde0). Only the two "value" slots carry + /// text; the label slots are left empty (retail sets them to "" for State A). + /// + private static void BindFooterStateA( + ImportedLayout layout, + UiDatFont? datFont, + Func data) + { + // 0x1000024e title → "" + Label(layout, FooterTitleId, datFont, Body, static () => string.Empty); + + // 0x10000242 line-1 label → "" + Label(layout, FooterLine1Label, datFont, Body, static () => string.Empty); + + // 0x10000243 line-1 value → "Select an Attribute to Improve" + // StringId "ID_StatManagement_Footer_SelectAttribute" + Label(layout, FooterLine1Value, datFont, Body, + static () => "Select an Attribute to Improve"); + + // 0x10000244 line-2 label → "" + Label(layout, FooterLine2Label, datFont, Body, static () => string.Empty); + + // 0x10000245 line-2 value → InqInt(0x18) = available skill credits + Label(layout, FooterLine2Value, datFont, Body, + () => data().SkillCredits.ToString()); + } + + // ── 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) @@ -104,8 +356,6 @@ public static class CharacterStatController return false; } - private const float LineHeight = 18f; - private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (layout.FindElement(id) is UiText t) @@ -116,24 +366,4 @@ public static class CharacterStatController t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } } - - /// - /// The six innate attributes in canonical AC panel order - /// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header. - /// - private static IReadOnlyList AttributeRows(CharacterSheet s) - => new List - { - new("Attributes", Gold), - new(string.Empty, Body), - Row("Strength", s.Strength), - Row("Endurance", s.Endurance), - Row("Coordination", s.Coordination), - Row("Quickness", s.Quickness), - Row("Focus", s.Focus), - Row("Self", s.Self), - }; - - private static UiText.Line Row(string name, int value) - => new($"{name,-13}{value,4}", Body); } diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index c89f4ae7..6e7222ab 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -71,6 +71,13 @@ public sealed class UiText : UiElement /// after the rewire. Pair with ClickThrough = true for non-interactive labels. public bool Centered { get; set; } + /// Static right-aligned single-line mode: draws the FIRST line right-justified + /// within the element rect, vertically centered, with NO scroll/selection machinery. + /// Used for value labels in attribute/skill rows where the number must hug the right edge. + /// Mutually exclusive with — if both are true, Centered takes + /// precedence. Pair with ClickThrough = true for non-interactive labels. + public bool RightAligned { get; set; } + /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); @@ -153,6 +160,28 @@ public sealed class UiText : UiElement return; } + // Static right-aligned single-line mode: draw the first line flush with the right + // edge, vertically centered, then skip the scroll/selection machinery. + if (RightAligned) + { + var rLines = LinesProvider(); + if (rLines.Count == 0) return; + var line0 = rLines[0]; + if (DatFont is { } rdf) + { + float rx = Width - rdf.MeasureWidth(line0.Text) - Padding; + float ry = (Height - rdf.LineHeight) * 0.5f; + ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color); + } + else if ((Font ?? ctx.DefaultFont) is { } rbf) + { + float rx = Width - rbf.MeasureWidth(line0.Text) - Padding; + float ry = (Height - rbf.LineHeight) * 0.5f; + ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf); + } + return; + } + // Prefer the retail dat font when set; fall back to BitmapFont. var datFont = DatFont; var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 70faab1a..58152ca6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -6,8 +6,8 @@ namespace AcDream.App.Tests.UI.Layout; /// /// Unit tests for — the Attributes-tab controller that -/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data -/// wiring against fake layouts; no dats, no GL. +/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay). +/// Pure data wiring against fake layouts; no dats, no GL. /// public class CharacterStatControllerTests { @@ -64,26 +64,231 @@ public class CharacterStatControllerTests Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}"); } - // ── Attribute list ─────────────────────────────────────────────────────── + // ── Attribute list — 9 rows in list box 0x1000023D ─────────────────────── + /// Bind adds exactly 9 UiPanel row containers to the list box. [Fact] - public void Bind_PopulatesAttributeList() + public void Bind_AttributeList_Has9Rows() { - var list = new UiPanel(); + var list = new UiPanel(); var layout = Fake((CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().First(); - var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text)); + var rows = list.Children.OfType().ToList(); + Assert.Equal(9, rows.Count); + } - Assert.Contains("Strength", text); - Assert.Contains("Endurance", text); - Assert.Contains("Coordination", text); - Assert.Contains("Quickness", text); - Assert.Contains("Focus", text); - Assert.Contains("Self", text); - Assert.Contains("100", text); + /// + /// 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_EachRowHasRightAlignedValueLabel() + { + 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); + 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 + 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() + { + 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); + + string[] expectedNames = + { + "Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self", + "Health", "Stamina", "Mana", + }; + + 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) + 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() + { + var list = new UiPanel(); + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + 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 + + 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); + } + + [Fact] + public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver() + { + var list = new UiPanel(); + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); + + var rows = list.Children.OfType().ToList(); + Assert.Equal(9, rows.Count); + foreach (var row in rows) + { + var iconEl = row.Children.OfType().First(); + Assert.Equal(0u, iconEl.BackgroundSprite); + } + } + + // ── Footer State A ──────────────────────────────────────────────────────── + + [Fact] + public void Bind_FooterStateA_TitleEmpty() + { + var title = new UiText(); + var layout = Fake((CharacterStatController.FooterTitleId, title)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal(string.Empty, title.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_FooterStateA_Line1LabelEmpty() + { + var lbl = new UiText(); + var layout = Fake((CharacterStatController.FooterLine1Label, lbl)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_FooterStateA_Line1ValueIsSelectPrompt() + { + var val = new UiText(); + var layout = Fake((CharacterStatController.FooterLine1Value, val)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Select an Attribute to Improve", val.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_FooterStateA_Line2LabelEmpty() + { + var lbl = new UiText(); + var layout = Fake((CharacterStatController.FooterLine2Label, lbl)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_FooterStateA_Line2ValueIsSkillCredits() + { + var val = new UiText(); + var layout = Fake((CharacterStatController.FooterLine2Value, val)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + // SampleData.SampleCharacter().SkillCredits == 96 + Assert.Equal("96", val.LinesProvider()[0].Text); + } + + // ── SkillCredits propagation through SampleData ─────────────────────────── + + [Fact] + public void SampleCharacter_SkillCredits_Is96() + { + Assert.Equal(96, SampleData.SampleCharacter().SkillCredits); + } + + // ── UiText.RightAligned — unit-level draw mode flag ────────────────────── + + [Fact] + public void UiText_RightAligned_DefaultFalse() + { + var t = new UiText(); + Assert.False(t.RightAligned); + } + + [Fact] + public void UiText_RightAligned_CanBeSetTrue() + { + var t = new UiText { RightAligned = true }; + Assert.True(t.RightAligned); } // ── Robustness: a partial layout (missing ids) must not throw ────────────