acdream/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
Erik 4ade9b0427 feat(ui): Campaign AS AS4 — society/allegiance/fellowship + configurable extras (G6/G7/G8)
Ports the last three retail extras-list row families from CharExamineUI::
SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing
the character-path extras list end to end (AS2 header + AS3 armor-level/
ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster
path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and
pinned by a controller-level regression test.

Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281
(Faction1Bits) being PRESENT — a literal reading of InqInt's found/not-
found return, not the ground-truth doc's informal "!= 0" value test.
Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 ->
"???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood)
comes straight off the decompiled branches. Rank-band suffix boundaries
(1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord /
1001-1500 Master, no suffix outside every band) read directly off the
decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the
ground-truth table exactly. Color rule: green when the LOCAL player
shares the target's selected bit (checked first, so extra local bits
don't override a match), red when local has a different bit but not the
target's, normal when local has no society bits; the unrecognized "???"
branch never gets a color (retail's ebx_3 stays at its zero initializer).
The local player's own Faction1Bits comes from a new pure `localFactionBits`
parameter on BuildExtra — the composer never reads state directly.

Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated
by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's
header AllegianceName binding already consumes. Four arms in retail's
exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped
"%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present
+ PatronsTitle (Str 35) absent -> "Monarch:" only; both present and
ordinally equal -> one "Monarch/Patron:" row; both present and different
-> "Monarch:" then "Patron:".

Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship
(Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125
Age via the ALREADY-PORTED RetailDurationText.Format — the same
ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at
@0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing
Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with
the SAME "Deaths:" label, verified in the decomp), Titles Earned
(Int 262). Each row gates independently on its own property being
PRESENT (server already strips these per the target's visibility options
per ground truth §3) — no client-side option or success gating added. No
spacers separate these seven rows, matching the decomp exactly.

Seam: AppraisalUiController takes a new `Func<int> _localFactionBits`
dependency (per-call, never captured once — the secure-trade deferred-Func
lesson), invoked only on the character path. AppraisalRuntimeBindings
gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from
`d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState
instance CharacterSheetProvider already reads from, no new state path.

AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now
applies a SECOND response with different armor-level values (proving the
refresh re-renders from fresh data) then a THIRD with armorLevels: null
(proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the
full authored row order with the @0x004b5d7d legend anchor. The test
Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags
whenever armorLevels is supplied (realism sub-nit), applying uniformly to
every existing armor-level test in the file.

Ground-truth doc imprecisions found while verifying against the decomp
directly: (1) the Society gate is presence-of-property, not value != 0 —
InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing
describes the common case but not the literal branch condition; (2) the
Society color rule's bit-priority (same-bit match checked before the
other-bits check) wasn't spelled out in the summary table, only "same/
different/none" — confirmed exact by reading all three branches
(@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found.

Register: docs/architecture/retail-divergence-register.md row AP-110
retires the "exhaustive character detail regions" clause from its
still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4
together closed the character-path extras list); the row's other
residuals (item-object preview, effective shield projection, cooldown-
remaining, augmentation-cost StringInfo, creature FontInfo-list
selection) are untouched.

Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society
gate/bit-priority/band-boundaries/color-vs-local-faction, the full
allegiance cascade incl. follower singular/plural/clamp, each
configurable extra present/absent, "Has never died", monster-path
regression, one complete ordering-pin snapshot) plus 3 new controller-
level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/
template binding (localFactionBits seam wired end to end, its default
fallback, and the strengthened monster-path regression). Hermetic
AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410
passed across all projects, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:45:57 +02:00

682 lines
26 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CreatureAppraisalRowsTests
{
[Fact]
public void FailedAssessmentShowsOnlyHealthPercentAndUnknownOtherValues()
{
var profile = Profile(
health: 25u,
healthMax: 100u,
highlights: (ushort)0,
colors: (ushort)0);
IReadOnlyList<CreatureAppraisalRow> rows =
CreatureAppraisalRows.Build(profile, success: false);
Assert.All(
rows,
row => Assert.Equal(
CreatureAppraisalValueStyle.Incomplete,
row.Style));
Assert.Equal("25 %", rows[6].Value);
Assert.Equal("???", rows[0].Value);
Assert.Equal("???", rows[7].Value);
Assert.Equal("???", rows[8].Value);
}
[Fact]
public void EnchantmentBitsSelectRetailPositiveAndNegativeStyles()
{
var profile = Profile(
health: 100u,
healthMax: 100u,
highlights: (ushort)((1 << 0) | (1 << 6)),
colors: (ushort)(1 << 0));
IReadOnlyList<CreatureAppraisalRow> rows =
CreatureAppraisalRows.Build(profile, success: true);
Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style);
Assert.Equal(CreatureAppraisalValueStyle.Negative, rows[6].Style);
Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style);
}
[Fact]
public void ExtraRatingsFollowRetailGroupingFormattingAndSeparators()
{
var properties = new PropertyBundle();
properties.Ints[0x133u] = 35;
properties.Ints[0x139u] = 7;
properties.Ints[0x13Au] = 4;
properties.Ints[0x134u] = 12;
properties.Ints[0x13Bu] = 3;
properties.Ints[0x13Cu] = 2;
properties.Ints[0x15Eu] = 9;
properties.Ints[0x15Fu] = 6;
IReadOnlyList<CreatureAppraisalRow> rows =
CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false);
Assert.Equal(5, rows.Count);
Assert.Equal(("", ""), (rows[0].Label, rows[0].Value));
Assert.Equal(
("Dmg/CritDmg", "Rating: 35/4"),
(rows[1].Label, rows[1].Value));
Assert.Equal(
("Dmg/CritDmg", "Resist: 12/2"),
(rows[2].Label, rows[2].Value));
Assert.Equal(
("DoT/Life:", "Resist: 9/6"),
(rows[3].Label, rows[3].Value));
Assert.Equal(("", ""), (rows[4].Label, rows[4].Value));
}
[Fact]
public void CritOnlyTriggersRatingRowWhileHealingBoostAloneDoesNot()
{
var critOnly = new PropertyBundle();
critOnly.Ints[0x139u] = 8;
IReadOnlyList<CreatureAppraisalRow> rows =
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, 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));
}
// ── Campaign AS slice AS4: society/allegiance/fellowship + configurable
// extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as-
// ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: Society
// @0x004b49a1-@0x004b4c24, Monarch/Patron/Followers
// @0x004b4d97-@0x004b4f54, configurable extras
// @0x004b58be-@0x004b5c4d.
[Fact]
public void SocietyRowAbsentWhenFaction1BitsPropertyNotPresent()
{
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
new PropertyBundle(), armorLevels: null, character: true);
Assert.DoesNotContain(rows, r => r.Label == "Society:");
}
[Theory]
[InlineData(0x1, 287u, "Celestial Hand")]
[InlineData(0x2, 288u, "Eldrytch Web")]
[InlineData(0x4, 289u, "Radiant Blood")]
public void SocietyRowSelectsNameAndRankPropertyPerTargetBit(
int targetBit, uint rankPropertyId, string expectedName)
{
var properties = new PropertyBundle();
properties.Ints[281u] = targetBit;
properties.Ints[rankPropertyId] = 50; // Initiate band
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
Assert.Equal($"{expectedName} ~ Initiate", row.Value);
}
[Theory]
[InlineData(0x8)]
[InlineData(0x10)]
public void SocietyRowFallsBackToUnrecognizedForUnknownBitCombinations(int bits)
{
var properties = new PropertyBundle();
properties.Ints[281u] = bits;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true, localFactionBits: 0x1);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
Assert.Equal("???", row.Value);
// Retail's ebx_3 color accumulator never gets touched on this branch.
Assert.Equal(CreatureAppraisalValueStyle.Normal, row.Style);
}
[Theory]
[InlineData(0, "")]
[InlineData(1, " ~ Initiate")]
[InlineData(100, " ~ Initiate")]
[InlineData(101, " ~ Adept")]
[InlineData(300, " ~ Adept")]
[InlineData(301, " ~ Knight")]
[InlineData(600, " ~ Knight")]
[InlineData(601, " ~ Lord")]
[InlineData(1000, " ~ Lord")]
[InlineData(1001, " ~ Master")]
[InlineData(1500, " ~ Master")]
[InlineData(1501, "")]
public void SocietyRankBandSuffixMatchesRetailInclusiveBoundaries(
int rank, string expectedSuffix)
{
var properties = new PropertyBundle();
properties.Ints[281u] = 0x1; // Celestial Hand
properties.Ints[287u] = rank;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
Assert.Equal("Celestial Hand" + expectedSuffix, row.Value);
}
[Theory]
[InlineData(0x1, 0x1, CreatureAppraisalValueStyle.Positive)]
[InlineData(0x1, 0x2, CreatureAppraisalValueStyle.Negative)]
[InlineData(0x1, 0x4, CreatureAppraisalValueStyle.Negative)]
[InlineData(0x1, 0x0, CreatureAppraisalValueStyle.Normal)]
[InlineData(0x2, 0x2, CreatureAppraisalValueStyle.Positive)]
[InlineData(0x2, 0x1, CreatureAppraisalValueStyle.Negative)]
[InlineData(0x4, 0x4, CreatureAppraisalValueStyle.Positive)]
[InlineData(0x4, 0x2, CreatureAppraisalValueStyle.Negative)]
public void SocietyColorReflectsLocalPlayerFactionBitsAgainstTarget(
int targetBit, int localFactionBits, CreatureAppraisalValueStyle expected)
{
var properties = new PropertyBundle();
properties.Ints[281u] = targetBit;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true, localFactionBits);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
Assert.Equal(expected, row.Style);
}
[Fact]
public void SocietyColorPrioritizesSameBitMatchEvenWhenLocalHasOtherBitsToo()
{
var properties = new PropertyBundle();
properties.Ints[281u] = 0x1;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true, localFactionBits: 0x1 | 0x2);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
Assert.Equal(CreatureAppraisalValueStyle.Positive, row.Style);
}
[Fact]
public void AllegianceCascadeAbsentWhenAllegianceRankBelowOne()
{
var properties = new PropertyBundle();
properties.Ints[30u] = 0;
properties.Strings[21u] = "Should Not Appear";
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"
or "Monarch/Patron:" or "Alleg. Monarch:");
}
[Theory]
[InlineData(0, "0 Followers")]
[InlineData(1, "1 Follower")]
[InlineData(2, "2 Followers")]
[InlineData(-5, "0 Followers")]
public void AllegianceCascadeShowsClampedFollowerCountWhenMonarchsTitleAbsent(
int followers, string expected)
{
var properties = new PropertyBundle();
properties.Ints[30u] = 1;
properties.Ints[35u] = followers;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Alleg. Monarch:");
Assert.Equal(expected, row.Value);
}
[Fact]
public void AllegianceCascadeShowsMonarchOnlyWhenPatronsTitleAbsent()
{
var properties = new PropertyBundle();
properties.Ints[30u] = 1;
properties.Strings[21u] = "Baroness Aluvia";
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
Assert.Equal(
"Baroness Aluvia", Assert.Single(rows, r => r.Label == "Monarch:").Value);
Assert.DoesNotContain(rows, r => r.Label is "Patron:" or "Monarch/Patron:");
}
[Fact]
public void AllegianceCascadeCombinesMonarchAndPatronWhenTitlesAreOrdinallyEqual()
{
var properties = new PropertyBundle();
properties.Ints[30u] = 1;
properties.Strings[21u] = "Same Title";
properties.Strings[35u] = "Same Title";
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
Assert.Equal(
"Same Title", Assert.Single(rows, r => r.Label == "Monarch/Patron:").Value);
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:");
}
[Fact]
public void AllegianceCascadeSplitsMonarchAndPatronWhenTitlesDiffer()
{
var properties = new PropertyBundle();
properties.Ints[30u] = 1;
properties.Strings[21u] = "Monarch Title";
properties.Strings[35u] = "Patron Title";
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
Assert.Equal(
"Monarch Title", Assert.Single(rows, r => r.Label == "Monarch:").Value);
Assert.Equal(
"Patron Title", Assert.Single(rows, r => r.Label == "Patron:").Value);
Assert.DoesNotContain(rows, r => r.Label == "Monarch/Patron:");
}
[Fact]
public void ConfigurableExtrasEachAppearOnlyWhenTheirOwnPropertyIsPresent()
{
var properties = new PropertyBundle();
properties.Strings[10u] = "The Fellows";
properties.Strings[43u] = "1/1/2023"; // DateOfBirth (String table)
properties.Ints[125u] = 90; // Age, seconds
properties.Ints[181u] = 7;
properties.Ints[192u] = 42;
properties.Ints[262u] = 3;
// Int 43 (NumDeaths) deliberately absent — separate Int-table id
// from the String-table DateOfBirth id above; must not leak a row.
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
Assert.Equal(
"The Fellows", Assert.Single(rows, r => r.Label == "Fellowship:").Value);
Assert.Equal(
"1/1/2023",
Assert.Single(rows, r => r.Label == "Arrived in Dereth:").Value);
Assert.Equal(
RetailDurationText.Format(90),
Assert.Single(rows, r => r.Label == "Time in Dereth:").Value);
Assert.Equal("7", Assert.Single(rows, r => r.Label == "Chess Rank:").Value);
Assert.Equal("42", Assert.Single(rows, r => r.Label == "Fishing Skill:").Value);
Assert.Equal("3", Assert.Single(rows, r => r.Label == "Titles Earned:").Value);
Assert.DoesNotContain(rows, r => r.Label == "Deaths:");
}
[Fact]
public void ConfigurableExtrasAllAbsentLeavesOnlyTheLegend()
{
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
new PropertyBundle(), armorLevels: null, character: true);
CreatureAppraisalRow only = Assert.Single(rows);
Assert.Equal("* = Unenchantable", only.Label);
}
[Theory]
[InlineData(0, "Has never died")]
[InlineData(-3, "Has never died")]
[InlineData(1, "1")]
[InlineData(5, "5")]
public void DeathsRowShowsHasNeverDiedAtOrBelowZeroButKeepsTheSameLabel(
int deaths, string expected)
{
var properties = new PropertyBundle();
properties.Ints[43u] = deaths; // NumDeaths (Int table)
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: true);
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Deaths:");
Assert.Equal(expected, row.Value);
}
[Fact]
public void MonsterPathNeverGainsSocietyAllegianceOrConfigurableExtraRows()
{
var properties = new PropertyBundle();
properties.Ints[281u] = 0x1;
properties.Ints[30u] = 5;
properties.Strings[21u] = "Monarch Title";
properties.Strings[10u] = "Fellows";
properties.Strings[43u] = "1/1/2023";
properties.Ints[125u] = 90;
properties.Ints[181u] = 7;
properties.Ints[192u] = 42;
properties.Ints[43u] = 2;
properties.Ints[262u] = 3;
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, armorLevels: null, character: false, localFactionBits: 0x1);
Assert.DoesNotContain(rows, r => r.Label == "Society:");
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"
or "Monarch/Patron:" or "Alleg. Monarch:");
Assert.DoesNotContain(rows, r => r.Label == "Fellowship:");
Assert.DoesNotContain(rows, r => r.Label == "Arrived in Dereth:");
Assert.DoesNotContain(rows, r => r.Label == "Time in Dereth:");
Assert.DoesNotContain(rows, r => r.Label == "Chess Rank:");
Assert.DoesNotContain(rows, r => r.Label == "Fishing Skill:");
Assert.DoesNotContain(rows, r => r.Label == "Deaths:");
Assert.DoesNotContain(rows, r => r.Label == "Titles Earned:");
Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable");
}
[Fact]
public void CompleteCharacterExtrasOrderingMatchesRetailRowSequence()
{
var properties = new PropertyBundle();
// Society: local shares the target's Celestial Hand bit -> green.
properties.Ints[281u] = 0x1;
properties.Ints[287u] = 50;
// Allegiance cascade: monarch/patron present and different.
properties.Ints[30u] = 5;
properties.Strings[21u] = "Monarch Title";
properties.Strings[35u] = "Patron Title";
// Ratings: only the Dmg/CritDmg family fires.
properties.Ints[0x133u] = 10;
// Configurable extras: only Fellowship and Deaths present.
properties.Strings[10u] = "Fellows";
properties.Ints[43u] = 0;
var levels = new AppraiseInfoParser.ArmorLevel(
Head: 1, Chest: 0, Abdomen: 0,
UpperArm: 0, LowerArm: 0, Hand: 0,
UpperLeg: 0, LowerLeg: 0, Foot: 0);
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
properties, levels, character: true, localFactionBits: 0x1);
Assert.Equal(
[
"Society:",
"Monarch:",
"Patron:",
"", // AL trio leading spacer
"Head/Chest/Groin",
"Bicep/Wrist/Hand",
"Thigh/Shin/Foot",
"", // ratings leading spacer
"Dmg/CritDmg",
"", // ratings trailing spacer
"Fellowship:",
"Deaths:",
"* = Unenchantable",
],
rows.Select(r => r.Label));
Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style);
}
[Fact]
public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry()
{
ElementInfo template =
FixtureLoader.LoadExaminationRowTemplateInfos();
Assert.Equal(CreatureAppraisalRowTemplateFactory.TemplateId, template.Id);
Assert.Equal((292f, 20f), (template.Width, template.Height));
ElementInfo label = Assert.Single(
template.Children,
child => child.Id == CreatureAppraisalRowTemplateFactory.LabelId);
ElementInfo value = Assert.Single(
template.Children,
child => child.Id == CreatureAppraisalRowTemplateFactory.ValueId);
Assert.Equal((0f, 128f), (label.X, label.Width));
Assert.Equal((27f, 256f), (value.X, value.Width));
Assert.Equal(0x40000001u, label.FontDid);
Assert.Equal(0x40000001u, value.FontDid);
}
[Fact]
public void CreatureNameResolverUsesLoadedRetailMappingAndSafeFallback()
{
var resolver = new CreatureDisplayNameResolver(
new Dictionary<uint, string> { [77u] = "Ghost" });
Assert.Equal("Ghost", resolver.Resolve(77));
Assert.Equal(string.Empty, resolver.Resolve(0));
Assert.Equal(string.Empty, resolver.Resolve(999));
}
[Fact]
public void LayeredTemplateSeparatesChromeFromForegroundText()
{
var templates = new CreatureAppraisalRowTemplateFactory(
FixtureLoader.LoadExaminationRowTemplateInfos(),
_ => (0u, 0, 0),
defaultFont: null);
var row = new CreatureAppraisalRow(
"Strength",
"120",
CreatureAppraisalValueStyle.Normal);
UiTemplateListSlot background = templates.Create(
row,
CreatureAppraisalRowLayer.Background);
UiTemplateListSlot foreground = templates.Create(
row,
CreatureAppraisalRowLayer.Foreground);
Assert.Empty(((UiText)background.Content.FindElement(
CreatureAppraisalRowTemplateFactory.LabelId)!).LinesProvider());
Assert.Empty(((UiText)background.Content.FindElement(
CreatureAppraisalRowTemplateFactory.ValueId)!).LinesProvider());
Assert.False(
Assert.IsType<UiDatElement>(foreground.Content.Root).MediaVisible);
Assert.Equal(
"Strength",
Assert.Single(((UiText)foreground.Content.FindElement(
CreatureAppraisalRowTemplateFactory.LabelId)!)
.LinesProvider()).Text);
Assert.Equal(
"120",
Assert.Single(((UiText)foreground.Content.FindElement(
CreatureAppraisalRowTemplateFactory.ValueId)!)
.LinesProvider()).Text);
}
private static AppraiseInfoParser.CreatureProfile Profile(
uint health,
uint healthMax,
ushort highlights,
ushort colors)
=> new(
Flags: 0x09u,
Health: health,
HealthMax: healthMax,
Strength: null,
Endurance: null,
Quickness: null,
Coordination: null,
Focus: null,
Self: null,
Stamina: 50u,
Mana: 75u,
StaminaMax: 100u,
ManaMax: 100u,
AttributeHighlights: highlights,
AttributeColors: colors);
}