diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index a522af1a..bac95ade 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -718,7 +718,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _creatureNames.Resolve(GetInt(p, 2u))); } - RebuildCreatureStats(appraisal); + RebuildCreatureStats(appraisal, character); if (newlySelected) ResetCreatureScroll(); @@ -810,7 +810,22 @@ public sealed class AppraisalUiController : IRetainedPanelController private static string BuildAllegianceDisplay(PropertyBundle p) => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; - private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal) + /// + /// Rebuilds both authored appraisal lists from a freshly parsed response. + /// selects between the CharExamineUI + /// and CreatureExamineUI extras composition (armor-level trio + + /// unenchantable legend are CHAR-ONLY; see + /// ). appraisal + /// already carries ArmorLevels straight from the freshly parsed + /// wire response (AppraiseInfoParser parses it unconditionally + /// when the flag is set) — the 0.75 s combat refresh + /// (RefreshCurrentAppraisal) round-trips a + /// brand-new response through , so no separate cache + /// is needed for a refresh to keep rendering the same armor-level rows. + /// + private void RebuildCreatureStats( + AppraiseInfoParser.Parsed appraisal, + bool character) { if (_creatureStats is null || _creatureRowTemplates is null) return; @@ -820,7 +835,8 @@ public sealed class AppraisalUiController : IRetainedPanelController ? CreatureAppraisalRows.Build(profile, appraisal.Success) : Array.Empty()); _creatureExtra?.Rebuild( - CreatureAppraisalRows.BuildExtra(appraisal.Properties)); + CreatureAppraisalRows.BuildExtra( + appraisal.Properties, appraisal.ArmorLevels, character)); } private void ConfigureScrollableText( diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index c150ee3d..db38926a 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -88,17 +88,71 @@ public static class CreatureAppraisalRows } /// - /// Port of CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0's - /// separate miscellaneous list. Retail reads all nine rating properties, - /// emits a leading and trailing blank row when any display group exists, - /// and uses Crit/CritResist only to decide whether their paired row exists. - /// HealingBoost is read but not displayed by this retail build. + /// Armor-level unenchantable sentinel. Retail formats each per-part value + /// as "%d" (data_794344) below this threshold and + /// "*%d" (data_7b110c) with value - 9999 at/above it + /// — see e.g. CharExamineUI::SetAppraiseInfo + /// @0x004B5086-@0x004B50A5 (the head part) and every + /// subsequent part in the trio. /// + private const int UnenchantableArmorLevel = 9999; + + /// + /// Port of the armor-level trio + rating rows from + /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and + /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path, + /// ratings only — no armor-level trio and no unenchantable legend; the + /// monster function ends immediately after its own trailing ratings + /// spacer and never touches base_armor_* or the + /// u"* = Unenchantable" literal). Ground truth: + /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b rows + /// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline). + /// + /// Rating gating/spacer logic: retail's CharExamineUI decompile + /// mangles its leading-spacer flag (ebx_13) into unreadable + /// call-argument artifacts — a known BN-decompiler loss (see + /// feedback_bn_decomp_field_names.md). Ruling R4 directs using + /// CreatureExamineUI's clean version of the identical algorithm + /// instead, which is what this method implements: one leading blank row + /// before the FIRST ratings-family row that fires, one trailing blank + /// row if ANY ratings-family row fired. Each of the three rating rows is + /// gated independently (307|313|314, 308|315|316, 350|351); 313/315 only + /// gate — their values (Crit/CritResist) are never displayed. + /// HealingBoost (Int 323) is read by retail and never rendered (ruling + /// R2) — discarded here too. + /// + /// + /// The response's parsed property tables. + /// + /// Parsed ArmorLevels blob (Success-only per ACE), or + /// when the response didn't carry one. + /// + /// + /// for the CharExamineUI (player) path, + /// which alone emits the armor-level trio and the trailing + /// "* = Unenchantable" legend; for the + /// CreatureExamineUI (monster) path, which never emits either. + /// public static IReadOnlyList BuildExtra( - PropertyBundle properties) + PropertyBundle properties, + AppraiseInfoParser.ArmorLevel? armorLevels, + bool character) { ArgumentNullException.ThrowIfNull(properties); + var rows = new List(); + + if (character && armorLevels is { } levels && HasAnyArmorLevel(levels)) + { + rows.Add(Blank()); + rows.Add(ArmorLevelRow( + "Head/Chest/Groin", levels.Head, levels.Chest, levels.Abdomen)); + rows.Add(ArmorLevelRow( + "Bicep/Wrist/Hand", levels.UpperArm, levels.LowerArm, levels.Hand)); + rows.Add(ArmorLevelRow( + "Thigh/Shin/Foot", levels.UpperLeg, levels.LowerLeg, levels.Foot)); + } + int damage = Get(properties, DamageRating); int damageResist = Get(properties, DamageResistRating); int crit = Get(properties, CritRating); @@ -113,38 +167,61 @@ public static class CreatureAppraisalRows bool showResist = damageResist > 0 || critResist > 0 || critDamageResist > 0; bool showDotLife = dotResist > 0 || lifeResist > 0; - if (!showRating && !showResist && !showDotLife) - return Array.Empty(); + if (showRating || showResist || showDotLife) + { + rows.Add(Blank()); + if (showRating) + { + rows.Add(new CreatureAppraisalRow( + "Dmg/CritDmg", + $"Rating: {Number(damage)}/{Number(critDamage)}", + CreatureAppraisalValueStyle.Normal)); + } + if (showResist) + { + rows.Add(new CreatureAppraisalRow( + "Dmg/CritDmg", + $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", + CreatureAppraisalValueStyle.Normal)); + } + if (showDotLife) + { + rows.Add(new CreatureAppraisalRow( + "DoT/Life:", + $"Resist: {Number(dotResist)}/{Number(lifeResist)}", + CreatureAppraisalValueStyle.Normal)); + } + rows.Add(Blank()); + } - var rows = new List(5) - { - Blank(), - }; - if (showRating) + if (character) { rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Rating: {Number(damage)}/{Number(critDamage)}", + "* = Unenchantable", + string.Empty, CreatureAppraisalValueStyle.Normal)); } - if (showResist) - { - rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", - CreatureAppraisalValueStyle.Normal)); - } - if (showDotLife) - { - rows.Add(new CreatureAppraisalRow( - "DoT/Life:", - $"Resist: {Number(dotResist)}/{Number(lifeResist)}", - CreatureAppraisalValueStyle.Normal)); - } - rows.Add(Blank()); + return rows; } + private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels) + => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0 + || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0 + || levels.UpperLeg > 0 || levels.LowerLeg > 0 || levels.Foot > 0; + + private static CreatureAppraisalRow ArmorLevelRow( + string label, int a, int b, int c) + => new( + label, + $"AL: {ArmorLevelPart(a)}/{ArmorLevelPart(b)}/{ArmorLevelPart(c)}", + CreatureAppraisalValueStyle.Normal); + + private static string ArmorLevelPart(int value) + => value >= UnenchantableArmorLevel + ? $"*{Number(value - UnenchantableArmorLevel)}" + : Number(value); + private static CreatureAppraisalRow Blank() => new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal); diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 94b523bd..73366bb6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -802,6 +802,173 @@ public sealed class AppraisalUiControllerTests Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); } + // ── Campaign AS slice AS3: armor-level rows + extras-list plumbing ──── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2b rows 3-7 + 15 (gap G4 + partial G8). Real LayoutDesc/template + // binding, matching the AS2 controller-level pattern. + + [Fact] + public void CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: armorLevels))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal(("", ""), ExtraRow(extra, 0)); + Assert.Equal( + ("Head/Chest/Groin", "AL: 100/110/120"), ExtraRow(extra, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 130/140/150"), ExtraRow(extra, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 160/170/180"), ExtraRow(extra, 3)); + Assert.Equal( + ("* = Unenchantable", string.Empty), ExtraRow(extra, 4)); + } + + [Fact] + public void CharacterResponse_CombatRefreshRetainsArmorLevelRows() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + var sent = new List(); + using var interaction = NewInteraction(objects, sent); + var combat = new CombatState(); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + combat, + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 50, Chest: 60, Abdomen: 70, + UpperArm: 80, LowerArm: 90, Hand: 100, + UpperLeg: 110, LowerLeg: 120, Foot: 130); + AppraiseInfoParser.Parsed appraisal = Parsed( + properties, MinimalCreatureProfile(), armorLevels: armorLevels); + + Assert.True(controller.Apply(appraisal)); + controller.OnShown(); + int sentBeforeRefresh = sent.Count; + + combat.SetCombatMode(CombatMode.Melee); + controller.Tick(0.75); + // The 0.75 s combat refresh fired exactly one fresh wire request. + Assert.Equal(sentBeforeRefresh + 1, sent.Count); + + // The refreshed response is a brand-new Parsed value coming back + // through Apply — AppraiseInfoParser always parses ArmorLevels when + // the flag is set, so nothing needs to be cached client-side for + // the re-applied response to keep rendering the same AL rows. + Assert.True(controller.Apply(appraisal)); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal( + ("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3)); + } + + [Fact] + public void CreatureResponse_NeverGainsArmorLevelTrioOrLegend() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Specter", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + // No String 5 / Int 261 marker -> monster path. ArmorLevels present + // on the wire (a real ACE response always carries them for a + // successful non-player target too) must still be ignored here. + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + + Assert.True(controller.Apply(Parsed( + new PropertyBundle(), + MinimalCreatureProfile(), + armorLevels: armorLevels))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(0, extra.GetNumUIItems()); + } + [Fact] public void ResponseForNeitherPendingNorCurrent_IsIgnored() { @@ -1263,7 +1430,8 @@ public sealed class AppraisalUiControllerTests PropertyBundle properties, AppraiseInfoParser.CreatureProfile? creature = null, uint guid = ObjectId, - bool success = true) + bool success = true, + AppraiseInfoParser.ArmorLevel? armorLevels = null) => new( Guid: guid, Flags: creature is null @@ -1276,7 +1444,7 @@ public sealed class AppraisalUiControllerTests CreatureProfile: creature, WeaponProfile: null, HookProfile: null, - ArmorLevels: null, + ArmorLevels: armorLevels, ArmorEnchantments: null, WeaponEnchantments: null, ResistEnchantments: null); @@ -1310,6 +1478,30 @@ public sealed class AppraisalUiControllerTests '\n', text.LinesProvider().Select(line => line.Text)); } + private static UiItemList CreatureExtraList(ImportedLayout layout) + { + UiElement extraHost = layout.FindElement( + AppraisalUiController.CreatureExtraListId)!; + UiElement creaturePanel = layout.FindElement( + AppraisalUiController.CreaturePanelId)!; + return Assert.Single( + creaturePanel.Children.OfType(), + candidate => candidate.Top == extraHost.Top); + } + + private static (string Label, string Value) ExtraRow( + UiItemList extra, int index) + { + var slot = Assert.IsType(extra.GetItem(index)); + string label = Assert.Single(((UiText)slot.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!) + .LinesProvider()).Text; + string value = Assert.Single(((UiText)slot.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!) + .LinesProvider()).Text; + return (label, value); + } + private static void AssertSpellText( ImportedLayout layout, uint elementId, diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs index 926573b7..cca7ea3a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -61,7 +61,7 @@ public sealed class CreatureAppraisalRowsTests properties.Ints[0x15Fu] = 6; IReadOnlyList rows = - CreatureAppraisalRows.BuildExtra(properties); + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); Assert.Equal(5, rows.Count); Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); @@ -84,14 +84,181 @@ public sealed class CreatureAppraisalRowsTests critOnly.Ints[0x139u] = 8; IReadOnlyList rows = - CreatureAppraisalRows.BuildExtra(critOnly); + CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); Assert.Equal(3, rows.Count); Assert.Equal("Rating: 0/0", rows[1].Value); var healingOnly = new PropertyBundle(); healingOnly.Ints[0x143u] = 20; - Assert.Empty(CreatureAppraisalRows.BuildExtra(healingOnly)); + Assert.Empty( + CreatureAppraisalRows.BuildExtra( + healingOnly, armorLevels: null, character: false)); + } + + // ── Campaign AS slice AS3: armor-level trio + extras-list ordering ──── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2b rows 3-7 + 15, rulings R3 (legend unconditional) and R4 (spacer + // discipline). Decomp anchors: CharExamineUI::SetAppraiseInfo + // @0x004B45F0 (armor-level trio @0x004B4FD1-@0x004B5410, legend + // @0x004B5D7D-@0x004B5DED) and CreatureExamineUI::SetAppraiseInfo + // @0x004B3FF0 (ratings-only; no trio, no legend). + + [Fact] + public void ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; // DamageRating -> also triggers ratings block + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, levels, character: true); + + // [0] spacer, [1..3] AL trio, [4] spacer, [5] rating, [6] spacer, + // [7] legend. + Assert.Equal(8, rows.Count); + Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); + Assert.Equal( + ("Head/Chest/Groin", "AL: 100/110/120"), + (rows[1].Label, rows[1].Value)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 130/140/150"), + (rows[2].Label, rows[2].Value)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 160/170/180"), + (rows[3].Label, rows[3].Value)); + Assert.Equal(("", ""), (rows[4].Label, rows[4].Value)); + Assert.Equal( + ("Dmg/CritDmg", "Rating: 35/0"), + (rows[5].Label, rows[5].Value)); + Assert.Equal(("", ""), (rows[6].Label, rows[6].Value)); + Assert.Equal( + ("* = Unenchantable", string.Empty), + (rows[7].Label, rows[7].Value)); + } + + [Theory] + [InlineData(9998, "9998")] + [InlineData(9999, "*0")] + [InlineData(10123, "*124")] + public void ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999( + int value, + string expected) + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: value, Chest: 0, Abdomen: 0, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + CreatureAppraisalRow row = Assert.Single( + rows, r => r.Label == "Head/Chest/Groin"); + Assert.Equal($"AL: {expected}/0/0", row.Value); + } + + [Fact] + public void ArmorLevelRowMixesStarredAndPlainPartsIndependently() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 50, Chest: 9999, Abdomen: 20000, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + CreatureAppraisalRow row = Assert.Single( + rows, r => r.Label == "Head/Chest/Groin"); + Assert.Equal("AL: 50/*0/*10001", row.Value); + } + + [Fact] + public void AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 0, Chest: 0, Abdomen: -5, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + Assert.DoesNotContain(rows, r => r.Label.Contains("Groin")); + Assert.DoesNotContain(rows, r => r.Label.Contains("Hand")); + Assert.DoesNotContain(rows, r => r.Label.Contains("Foot")); + // Legend is still unconditional on the char path. + Assert.Equal("* = Unenchantable", rows[^1].Label); + } + + [Fact] + public void ArmorLevelTrioAbsentWhenArmorLevelsIsNull() + { + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Value.StartsWith("AL:", StringComparison.Ordinal)); + } + + [Fact] + public void EachRatingRowGatesIndependently() + { + // Crit (313) alone gates the Rating row but never displays. + var critOnly = new PropertyBundle(); + critOnly.Ints[0x139u] = 1; + IReadOnlyList critRows = + CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); + Assert.Equal(3, critRows.Count); + Assert.Equal(("Dmg/CritDmg", "Rating: 0/0"), (critRows[1].Label, critRows[1].Value)); + + // CritResist (315) alone gates the Resist row but never displays. + var critResistOnly = new PropertyBundle(); + critResistOnly.Ints[0x13Bu] = 1; + IReadOnlyList critResistRows = + CreatureAppraisalRows.BuildExtra(critResistOnly, armorLevels: null, character: false); + Assert.Equal(3, critResistRows.Count); + Assert.Equal( + ("Dmg/CritDmg", "Resist: 0/0"), + (critResistRows[1].Label, critResistRows[1].Value)); + + // 350/351 (DoT/Life) alone, independent of the other two families. + var dotLifeOnly = new PropertyBundle(); + dotLifeOnly.Ints[0x15Eu] = 4; + IReadOnlyList dotLifeRows = + CreatureAppraisalRows.BuildExtra(dotLifeOnly, armorLevels: null, character: false); + Assert.Equal(3, dotLifeRows.Count); + Assert.Equal( + ("DoT/Life:", "Resist: 4/0"), + (dotLifeRows[1].Label, dotLifeRows[1].Value)); + } + + [Fact] + public void LegendIsAbsentOnMonsterPathEvenWithRatingsShown() + { + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); + + Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable"); + } + + [Fact] + public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + CreatureAppraisalRow only = Assert.Single(rows); + Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value)); } [Fact]