feat(ui): Campaign AS AS3 — per-bodypart armor-level rows (G4)

Plumbs Parsed.ArmorLevels into the extras composer and ports the retail
armor-level trio + unenchantable legend for the player examination window's
extras list (0x10000335), closing gap G4 and the legend half of G8 from
docs/research/2026-08-25-campaign-as-ground-truth.md.

Decomp evidence (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- CharExamineUI::SetAppraiseInfo @0x004B45F0: the armor-level trio
  (@0x004B4FD1-@0x004B5410) gates on ANY of nine base_armor_* fields > 0,
  emits one leading spacer, then three rows "Head/Chest/Groin" (Head,
  Chest, Abdomen), "Bicep/Wrist/Hand" (UpperArm, LowerArm, Hand),
  "Thigh/Shin/Foot" (UpperLeg, LowerLeg, Foot) formatted "AL: %s/%s/%s"
  with each part "%d" below 0x270f (9999) or "*%d" with (value-9999) at/
  above it (data_794344 vs data_7b110c). The trio precedes the ratings
  block and has no trailing spacer of its own.
- The "* = Unenchantable" legend (@0x004B5D7D-@0x004B5DED) is added
  UNCONDITIONALLY after the whole `if (InqCreature)` block closes —
  confirming ruling R3's "unconditional" reading directly from the raw
  decompile, not just the BN flattening theory.
- CreatureExamineUI::SetAppraiseInfo @0x004B3FF0 (monster path): reads the
  same nine ratings properties with the same gating/spacer logic, but
  never touches base_armor_* or the unenchantable literal. Confirmed the
  monster (character:false) path gains neither the trio nor the legend —
  CreatureAppraisalRows.BuildExtra is character-gated for both.
- Ruling R4 (spacer discipline): CharExamineUI's own ratings-block leading-
  spacer flag (ebx_13) is a known BN-decompiler artifact loss (call-
  argument mangling instead of a clean `= 1` assignment). Cross-checked
  against CreatureExamineUI's clean version of the identical algorithm:
  one leading spacer before the FIRST ratings-family row that fires, one
  trailing spacer if ANY fired. The existing BuildExtra ratings logic
  (per-row gates 307|313|314, 308|315|316, 350|351; single leading/
  trailing spacer) already matched this exactly — no functional change to
  the ratings section, only the signature/threading change to make room
  for the trio and legend around it.

Changed:
- CreatureAppraisalRows.BuildExtra now takes (properties, armorLevels,
  character) instead of (properties) alone. Character-gated trio + legend
  wrap the unchanged ratings logic.
- AppraisalUiController.RebuildCreatureStats takes the character flag and
  threads appraisal.ArmorLevels through; ApplyCreature passes its own
  `character` parameter. No caching needed for the combat refresh to keep
  the AL rows: AppraiseInfoParser always parses ArmorLevels into the fresh
  Parsed value Apply receives, so a re-Apply of the refreshed response
  renders the same rows for free.
- Test signature updates only (no behavior pins changed) plus new
  coverage: ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings,
  ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999 (theory: 9998/
  9999/10123), ArmorLevelRowMixesStarredAndPlainPartsIndependently,
  AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer,
  ArmorLevelTrioAbsentWhenArmorLevelsIsNull, EachRatingRowGatesIndependently,
  LegendIsAbsentOnMonsterPathEvenWithRatingsShown,
  LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras (rows-level);
  CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding,
  CharacterResponse_CombatRefreshRetainsArmorLevelRows,
  CreatureResponse_NeverGainsArmorLevelTrioOrLegend (controller-level,
  through the real LayoutImporter/FixtureLoader binding seam).

No existing pin was corrected — the pre-AS3 ratings gating/spacer
behavior already matched the decomp; only the call signature changed.

Full hermetic suite: AcDream.App.Tests 6208/0 skips; full-solution
15,483/0 skips. Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 09:16:17 +02:00
parent bde5cae031
commit 1616cd3d39
4 changed files with 490 additions and 38 deletions

View file

@ -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)
/// <summary>
/// Rebuilds both authored appraisal lists from a freshly parsed response.
/// <paramref name="character"/> selects between the <c>CharExamineUI</c>
/// and <c>CreatureExamineUI</c> extras composition (armor-level trio +
/// unenchantable legend are CHAR-ONLY; see
/// <see cref="CreatureAppraisalRows.BuildExtra"/>). <c>appraisal</c>
/// already carries <c>ArmorLevels</c> straight from the freshly parsed
/// wire response (<c>AppraiseInfoParser</c> parses it unconditionally
/// when the flag is set) — the 0.75 s combat refresh
/// (<see cref="Tick"/> → <c>RefreshCurrentAppraisal</c>) round-trips a
/// brand-new response through <see cref="Apply"/>, so no separate cache
/// is needed for a refresh to keep rendering the same armor-level rows.
/// </summary>
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<CreatureAppraisalRow>());
_creatureExtra?.Rebuild(
CreatureAppraisalRows.BuildExtra(appraisal.Properties));
CreatureAppraisalRows.BuildExtra(
appraisal.Properties, appraisal.ArmorLevels, character));
}
private void ConfigureScrollableText(

View file

@ -88,17 +88,71 @@ public static class CreatureAppraisalRows
}
/// <summary>
/// Port of <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c>'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 <c>"%d"</c> (<c>data_794344</c>) below this threshold and
/// <c>"*%d"</c> (<c>data_7b110c</c>) with <c>value - 9999</c> at/above it
/// — see e.g. <c>CharExamineUI::SetAppraiseInfo</c>
/// <c>@0x004B5086</c>-<c>@0x004B50A5</c> (the head part) and every
/// subsequent part in the trio.
/// </summary>
private const int UnenchantableArmorLevel = 9999;
/// <summary>
/// Port of the armor-level trio + rating rows from
/// <c>CharExamineUI::SetAppraiseInfo @ 0x004B45F0</c> (player path) and
/// <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c> (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 <c>base_armor_*</c> or the
/// <c>u"* = Unenchantable"</c> literal). Ground truth:
/// <c>docs/research/2026-08-25-campaign-as-ground-truth.md</c> §2b rows
/// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline).
/// <para>
/// Rating gating/spacer logic: retail's <c>CharExamineUI</c> decompile
/// mangles its leading-spacer flag (<c>ebx_13</c>) into unreadable
/// call-argument artifacts — a known BN-decompiler loss (see
/// <c>feedback_bn_decomp_field_names.md</c>). Ruling R4 directs using
/// <c>CreatureExamineUI</c>'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.
/// </para>
/// </summary>
/// <param name="properties">The response's parsed property tables.</param>
/// <param name="armorLevels">
/// Parsed <c>ArmorLevels</c> blob (Success-only per ACE), or
/// <see langword="null"/> when the response didn't carry one.
/// </param>
/// <param name="character">
/// <see langword="true"/> for the <c>CharExamineUI</c> (player) path,
/// which alone emits the armor-level trio and the trailing
/// "* = Unenchantable" legend; <see langword="false"/> for the
/// <c>CreatureExamineUI</c> (monster) path, which never emits either.
/// </param>
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
PropertyBundle properties)
PropertyBundle properties,
AppraiseInfoParser.ArmorLevel? armorLevels,
bool character)
{
ArgumentNullException.ThrowIfNull(properties);
var rows = new List<CreatureAppraisalRow>();
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<CreatureAppraisalRow>();
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<CreatureAppraisalRow>(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);

View file

@ -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<uint>();
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<UiItemList>(),
candidate => candidate.Top == extraHost.Top);
}
private static (string Label, string Value) ExtraRow(
UiItemList extra, int index)
{
var slot = Assert.IsType<UiTemplateListSlot>(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,

View file

@ -61,7 +61,7 @@ public sealed class CreatureAppraisalRowsTests
properties.Ints[0x15Fu] = 6;
IReadOnlyList<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<CreatureAppraisalRow> rows =
CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable");
}
[Fact]
public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras()
{
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
new PropertyBundle(), armorLevels: null, character: true);
CreatureAppraisalRow only = Assert.Single(rows);
Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value));
}
[Fact]