feat(ui): Campaign AS AS2 — examination header identity block (G1/G2/G3/G5/G10)
Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:
- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
"<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
object's creature-type name only when HeritageGroup == 0. Reuses the
Campaign CT gender/heritage tables in CharacterIdentityText (new
GenderHeritageDisplay helper) and the controller's existing
CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
title line was never shown at all. Now resolves the CURRENT display
title from Int 261 CharacterTitleId through CharacterTitleResolver
(CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
back to String 5 Template verbatim when the id is absent or
unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
CharacterTitleResolver instance the D.2b Character panel already
owns (_bindings.Character.TitleResolver), resolved per call under
DatLock — never captured once at mount time, per the secure-trade
deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
Ruling R7: retail reads the LOCAL weenie's PWD bits
(ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
appraisal payload. Now reads the assessed ClientObject's
PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
Apply()'s existing bail-out when the object has left the local table
already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
incomplete" on failed assess — zero retail provenance, deleted
outright. Retail clears the element first (ClearCreatureText already
does this every ApplyCreature call) then sets String 47
AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
Scoped to the character branch since retail's CreatureExamineUI
(monsters) never binds this element at all.
None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.
First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.
Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").
Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0332466fdc
commit
f8a2258979
5 changed files with 533 additions and 15 deletions
File diff suppressed because one or more lines are too long
|
|
@ -43,6 +43,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
public const uint SpellFormulaListId = 0x1000032Du;
|
public const uint SpellFormulaListId = 0x1000032Du;
|
||||||
|
|
||||||
private const uint TemplateStringProperty = 5u;
|
private const uint TemplateStringProperty = 5u;
|
||||||
|
/// <summary>
|
||||||
|
/// PropertyInt 0x105 (261 decimal) — CharacterTitleId. Doubles as the
|
||||||
|
/// Character-subview dispatch marker (<see cref="SelectView"/>) and, as
|
||||||
|
/// of AS2, the source id for <see cref="BuildCharacterTitleDisplay"/>'s
|
||||||
|
/// current-display-title line (element 0x10000151).
|
||||||
|
/// </summary>
|
||||||
private const uint CharacterMarkerIntProperty = 0x105u;
|
private const uint CharacterMarkerIntProperty = 0x105u;
|
||||||
private const uint DisplayedNameStringProperty = 0x34u;
|
private const uint DisplayedNameStringProperty = 0x34u;
|
||||||
private const double CreatureRefreshSeconds = 0.75;
|
private const double CreatureRefreshSeconds = 0.75;
|
||||||
|
|
@ -77,6 +83,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly Func<uint, uint> _resolveComponentIcon;
|
private readonly Func<uint, uint> _resolveComponentIcon;
|
||||||
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
|
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
|
||||||
private readonly Func<MagicSchool, uint> _magicSkill;
|
private readonly Func<MagicSchool, uint> _magicSkill;
|
||||||
|
/// <summary>
|
||||||
|
/// AS2: element <c>0x10000151</c>'s current-display-title chain
|
||||||
|
/// (<c>CharacterTitleResolver.Resolve</c> under the composition layer's
|
||||||
|
/// DatLock — resolved PER CALL, never captured once at mount time, per
|
||||||
|
/// the secure-trade deferred-Func lesson). Defaults to "never resolves"
|
||||||
|
/// so a caller that supplies nothing simply falls back to String 5
|
||||||
|
/// Template every time, matching retail's own fallback branch.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<uint, string?> _resolveCharacterTitle;
|
||||||
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
||||||
private readonly UiText _spellSchool;
|
private readonly UiText _spellSchool;
|
||||||
private readonly UiText _spellMana;
|
private readonly UiText _spellMana;
|
||||||
|
|
@ -132,7 +147,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Func<uint, uint>? resolveComponentIcon,
|
Func<uint, uint>? resolveComponentIcon,
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
||||||
Func<MagicSchool, uint>? magicSkill,
|
Func<MagicSchool, uint>? magicSkill,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates)
|
SpellExamineComponentTemplateFactory? spellComponentTemplates,
|
||||||
|
Func<uint, string?>? resolveCharacterTitle)
|
||||||
{
|
{
|
||||||
_layout = layout;
|
_layout = layout;
|
||||||
_objects = objects;
|
_objects = objects;
|
||||||
|
|
@ -159,6 +175,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
|
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
|
||||||
_spellComponents = spellComponents ?? (_ => []);
|
_spellComponents = spellComponents ?? (_ => []);
|
||||||
_magicSkill = magicSkill ?? (_ => 0u);
|
_magicSkill = magicSkill ?? (_ => 0u);
|
||||||
|
_resolveCharacterTitle = resolveCharacterTitle ?? (_ => null);
|
||||||
_spellComponentTemplates = spellComponentTemplates;
|
_spellComponentTemplates = spellComponentTemplates;
|
||||||
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
||||||
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
||||||
|
|
@ -287,7 +304,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Func<uint, uint>? resolveComponentIcon = null,
|
Func<uint, uint>? resolveComponentIcon = null,
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
Func<MagicSchool, uint>? magicSkill = null,
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
||||||
|
Func<uint, string?>? resolveCharacterTitle = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
ArgumentNullException.ThrowIfNull(objects);
|
ArgumentNullException.ThrowIfNull(objects);
|
||||||
|
|
@ -339,7 +357,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
resolveComponentIcon,
|
resolveComponentIcon,
|
||||||
spellComponents,
|
spellComponents,
|
||||||
magicSkill,
|
magicSkill,
|
||||||
spellComponentTemplates);
|
spellComponentTemplates,
|
||||||
|
resolveCharacterTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -687,9 +706,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
|
|
||||||
if (character)
|
if (character)
|
||||||
{
|
{
|
||||||
SetText(0x10000150u, GetString(p, 4u));
|
SetText(0x10000150u, BuildCharacterHeritageDisplay(p));
|
||||||
SetText(0x10000151u, GetString(p, 47u));
|
SetText(0x10000151u, BuildCharacterTitleDisplay(p));
|
||||||
SetText(0x10000152u, GetString(p, 11u));
|
SetText(0x10000152u, BuildPlayerKillerDisplay(obj));
|
||||||
|
SetText(0x1000053Au, BuildAllegianceDisplay(p));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -700,13 +720,91 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
|
|
||||||
RebuildCreatureStats(appraisal);
|
RebuildCreatureStats(appraisal);
|
||||||
|
|
||||||
SetText(0x1000053Au, appraisal.Success
|
|
||||||
? string.Empty
|
|
||||||
: "Assessment incomplete");
|
|
||||||
if (newlySelected)
|
if (newlySelected)
|
||||||
ResetCreatureScroll();
|
ResetCreatureScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes examination element <c>0x10000150</c> (Heritage) for a
|
||||||
|
/// player target. Ground truth: <c>docs/research/2026-08-25-campaign-as-
|
||||||
|
/// ground-truth.md</c> §2a. Gap G1.
|
||||||
|
/// </summary>
|
||||||
|
private string BuildCharacterHeritageDisplay(PropertyBundle p)
|
||||||
|
{
|
||||||
|
int gender = GetInt(p, CharacterIdentityText.GenderPropertyId);
|
||||||
|
int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId);
|
||||||
|
string creatureFallback = heritageGroup == 0
|
||||||
|
? _creatureNames.Resolve(GetInt(p, 2u))
|
||||||
|
: string.Empty;
|
||||||
|
return CharacterIdentityText.GenderHeritageDisplay(
|
||||||
|
gender, heritageGroup, creatureFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes examination element <c>0x10000151</c> (Profession/title) for
|
||||||
|
/// a player target: the current display title from Int 261
|
||||||
|
/// CharacterTitleId (<see cref="_resolveCharacterTitle"/>, resolved
|
||||||
|
/// through <c>CharacterTitleResolver</c>'s <c>CharacterTitleTable::
|
||||||
|
/// GetCharacterTitleFromID @0x005C6ED0</c> chain), falling back to
|
||||||
|
/// String 5 Template verbatim when the id is absent or unresolvable.
|
||||||
|
/// Ground truth §2a, gap G2. <see cref="CharacterMarkerIntProperty"/>
|
||||||
|
/// (0x105 == 261 decimal) is the SAME property <see cref="SelectView"/>
|
||||||
|
/// already keys the Character-subview dispatch on.
|
||||||
|
/// </summary>
|
||||||
|
private string BuildCharacterTitleDisplay(PropertyBundle p)
|
||||||
|
{
|
||||||
|
if (p.Ints.TryGetValue(CharacterMarkerIntProperty, out int titleId)
|
||||||
|
&& titleId != 0
|
||||||
|
&& _resolveCharacterTitle(unchecked((uint)titleId)) is { Length: > 0 } resolved)
|
||||||
|
{
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return GetString(p, TemplateStringProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
|
||||||
|
private const uint PlayerKillerPwdBit = 0x20u;
|
||||||
|
|
||||||
|
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
|
||||||
|
private const uint PlayerKillerLitePwdBit = 0x02000000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes examination element <c>0x10000152</c> (PlayerKiller) for a
|
||||||
|
/// player target. Ruling R7: retail reads the LOCAL weenie's PWD bits —
|
||||||
|
/// never the appraisal payload's PropertyInt 134 (AP-109's CT4 lesson).
|
||||||
|
/// <paramref name="obj"/> is the SAME <see cref="ClientObject"/>
|
||||||
|
/// <see cref="BuildTitle"/> already resolves names from — the assessed
|
||||||
|
/// object <see cref="Apply"/> located before <c>ApplyCreature</c> ever
|
||||||
|
/// runs. When that lookup fails, <see cref="Apply"/> already bails out
|
||||||
|
/// before reaching this method, matching retail's "weenie is gone, leave
|
||||||
|
/// the line cleared" behavior. Ground truth §2a, gap G3.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildPlayerKillerDisplay(ClientObject obj)
|
||||||
|
{
|
||||||
|
uint bitfield = obj.PublicWeenieBitfield ?? 0u;
|
||||||
|
if ((bitfield & PlayerKillerPwdBit) != 0u)
|
||||||
|
return "Player Killer";
|
||||||
|
if ((bitfield & PlayerKillerLitePwdBit) != 0u)
|
||||||
|
return "Player Killer Lite";
|
||||||
|
return "Non-Player Killer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes examination element <c>0x1000053A</c> (AllegianceName).
|
||||||
|
/// Retail clears the element first (<see cref="ClearCreatureText"/>
|
||||||
|
/// already does this for every <c>ApplyCreature</c> call), then sets it
|
||||||
|
/// to String 47 AllegianceName ONLY inside the Int 30 AllegianceRank
|
||||||
|
/// >= 1 gate — no gate, no allegiance line. Deletes the former
|
||||||
|
/// invented "Assessment incomplete" literal (zero retail provenance;
|
||||||
|
/// was the only occurrence in the repo). Ground truth §2a, gap G5.
|
||||||
|
/// Retail's <c>CreatureExamineUI</c> (monsters) never binds this
|
||||||
|
/// element at all — only <c>CharExamineUI</c> (players) does — so this
|
||||||
|
/// composer is scoped to the <c>character</c> branch, matching that
|
||||||
|
/// per-class split.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildAllegianceDisplay(PropertyBundle p)
|
||||||
|
=> GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty;
|
||||||
|
|
||||||
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
|
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
|
||||||
{
|
{
|
||||||
if (_creatureStats is null || _creatureRowTemplates is null)
|
if (_creatureStats is null || _creatureRowTemplates is null)
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,32 @@ internal static class CharacterIdentityText
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign AS slice AS2 (2026-08-25): examination window element
|
||||||
|
/// <c>0x10000150</c> (Heritage), <c>CharExamineUI::SetAppraiseInfo
|
||||||
|
/// @0x004B45F0</c> via <c>AppraisalSystem::InqGenderHeritageDisplay
|
||||||
|
/// @0x005B5AE0</c>. Unlike <see cref="StatHeaderLine"/> (the
|
||||||
|
/// stat-management panel's header, which never appends a title and has
|
||||||
|
/// no creature-type fallback), this composes ONLY
|
||||||
|
/// "<Gender> <Heritage>" — falling back to
|
||||||
|
/// <paramref name="creatureTypeFallback"/> (the assessed object's
|
||||||
|
/// creature-type display name, Int 2, resolved through the SAME
|
||||||
|
/// EnumMapper family <see cref="CreatureDisplayNameResolver"/> already
|
||||||
|
/// serves the monster view's own <c>0x1000014E</c> line) ONLY when
|
||||||
|
/// <paramref name="heritageGroup"/> is exactly 0, matching retail's own
|
||||||
|
/// integer comparison rather than "the lookup failed".
|
||||||
|
/// </summary>
|
||||||
|
public static string GenderHeritageDisplay(
|
||||||
|
int gender,
|
||||||
|
int heritageGroup,
|
||||||
|
string? creatureTypeFallback)
|
||||||
|
{
|
||||||
|
string? heritage = heritageGroup == 0
|
||||||
|
? creatureTypeFallback
|
||||||
|
: HeritageGroupDisplayName(heritageGroup);
|
||||||
|
return Join(GenderDisplayName(gender), heritage);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Join(params string?[] parts)
|
private static string Join(params string?[] parts)
|
||||||
{
|
{
|
||||||
return string.Join(" ", parts
|
return string.Join(" ", parts
|
||||||
|
|
|
||||||
|
|
@ -2113,6 +2113,20 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AS2 (Campaign AS, gap G2): the examination header's title line
|
||||||
|
// (element 0x10000151) reuses the SAME CharacterTitleResolver
|
||||||
|
// instance the D.2b Character panel already owns
|
||||||
|
// (_bindings.Character.TitleResolver, constructed once per
|
||||||
|
// composition in InteractionRetainedUiComposition — see
|
||||||
|
// MountCharacter's identical TitleResolver local function). Resolved
|
||||||
|
// PER CALL under DatLock, never captured once, per the secure-trade
|
||||||
|
// deferred-Func lesson.
|
||||||
|
string? ResolveCharacterTitle(uint titleId)
|
||||||
|
{
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
return _bindings.Character.TitleResolver.Resolve(titleId);
|
||||||
|
}
|
||||||
|
|
||||||
AppraisalUiController? controller = AppraisalUiController.Bind(
|
AppraisalUiController? controller = AppraisalUiController.Bind(
|
||||||
layout,
|
layout,
|
||||||
_bindings.Inventory.Objects,
|
_bindings.Inventory.Objects,
|
||||||
|
|
@ -2132,7 +2146,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
|
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
|
||||||
spellComponents: _bindings.Magic.SpellComponents,
|
spellComponents: _bindings.Magic.SpellComponents,
|
||||||
magicSkill: _bindings.Magic.MagicSkill,
|
magicSkill: _bindings.Magic.MagicSkill,
|
||||||
spellComponentTemplates: spellComponentTemplates);
|
spellComponentTemplates: spellComponentTemplates,
|
||||||
|
resolveCharacterTitle: ResolveCharacterTitle);
|
||||||
if (controller is null)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,352 @@ public sealed class AppraisalUiControllerTests
|
||||||
Assert.Equal(new[] { ObjectId, otherObjectId }, sent);
|
Assert.Equal(new[] { ObjectId, otherObjectId }, sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campaign AS slice AS2: examination header identity block ──────────
|
||||||
|
// Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md §2a,
|
||||||
|
// gap ledger G1/G2/G3/G5, rulings R6/R7. First-ever AppraisalView.Character
|
||||||
|
// controller coverage (gap G10).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_ComposesRetailHeaderIdentityBlock()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
PublicWeenieBitfield = 0x20u, // PWD bit 5 -> IsPK
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[0x71u] = 2; // Gender: Female
|
||||||
|
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
|
||||||
|
properties.Ints[0x105u] = 13; // CharacterTitleId (also the Character-view marker)
|
||||||
|
properties.Ints[30u] = 5; // AllegianceRank >= 1
|
||||||
|
properties.Strings[47u] = "The Empire"; // AllegianceName
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||||
|
|
||||||
|
Assert.Equal("Female Aluvian", HeaderText(layout, 0x10000150u));
|
||||||
|
Assert.Equal("War Mage", HeaderText(layout, 0x10000151u));
|
||||||
|
Assert.Equal("Player Killer", HeaderText(layout, 0x10000152u));
|
||||||
|
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Something Odd",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
creatureNames: new CreatureDisplayNameResolver(
|
||||||
|
new Dictionary<uint, string> { [42u] = "Olthoi Guardian" }))!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[2u] = 42; // CreatureType fallback source
|
||||||
|
properties.Ints[0xBCu] = 0; // HeritageGroup == 0 -> use creature-type fallback
|
||||||
|
properties.Strings[5u] = "Template"; // Character-view marker
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||||
|
Assert.Equal("Olthoi Guardian", HeaderText(layout, 0x10000150u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_TitleFallsBackToTemplateStringWhenTitleIdAbsent()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { })!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[5u] = "Test Template"; // both the fallback text AND the view marker
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||||
|
Assert.Equal("Test Template", HeaderText(layout, 0x10000151u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_TitleClearsWhenIdUnresolvableAndTemplateAbsent()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
// Never resolves any title id.
|
||||||
|
resolveCharacterTitle: _ => null)!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
// Int 261 present (satisfies the Character-view marker) but does not
|
||||||
|
// resolve, and String 5 Template is absent -> element clears.
|
||||||
|
properties.Ints[0x105u] = 999;
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x20u, "Player Killer")]
|
||||||
|
[InlineData(0x02000000u, "Player Killer Lite")]
|
||||||
|
[InlineData(0u, "Non-Player Killer")]
|
||||||
|
public void CharacterResponse_PlayerKillerLineReflectsLocalObjectPwdBits(
|
||||||
|
uint publicWeenieBitfield,
|
||||||
|
string expected)
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
PublicWeenieBitfield = publicWeenieBitfield,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { })!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[5u] = "Template";
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(expected, HeaderText(layout, 0x10000152u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingAssessedObject_PlayerKillerElementStaysClearedAndApplyReturnsFalse()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
// ObjectId is deliberately never added to the table — retail's
|
||||||
|
// "the weenie is gone" case (ruling R7).
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { })!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[5u] = "Template";
|
||||||
|
|
||||||
|
UiText pk = Assert.IsType<UiText>(layout.FindElement(0x10000152u));
|
||||||
|
Func<IReadOnlyList<UiText.Line>> providerBeforeApply = pk.LinesProvider;
|
||||||
|
|
||||||
|
Assert.False(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
|
||||||
|
// Apply bailed out before ApplyCreature/SetText ever ran for this
|
||||||
|
// element — it never had the chance to be anything but cleared.
|
||||||
|
Assert.Same(providerBeforeApply, pk.LinesProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllegianceElement_ClearsUnlessRankIsAtLeastOne()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { })!;
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
|
||||||
|
var noRank = new PropertyBundle();
|
||||||
|
noRank.Strings[5u] = "Template";
|
||||||
|
noRank.Strings[47u] = "The Empire";
|
||||||
|
Assert.True(controller.Apply(Parsed(noRank, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
|
||||||
|
|
||||||
|
var zeroRank = new PropertyBundle();
|
||||||
|
zeroRank.Strings[5u] = "Template";
|
||||||
|
zeroRank.Strings[47u] = "The Empire";
|
||||||
|
zeroRank.Ints[30u] = 0;
|
||||||
|
Assert.True(controller.Apply(Parsed(zeroRank, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
|
||||||
|
|
||||||
|
var gatedRank = new PropertyBundle();
|
||||||
|
gatedRank.Strings[5u] = "Template";
|
||||||
|
gatedRank.Strings[47u] = "The Empire";
|
||||||
|
gatedRank.Ints[30u] = 1;
|
||||||
|
Assert.True(controller.Apply(Parsed(gatedRank, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedAssess_StillRendersHeaderLinesFromPresentTableEntries()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
PublicWeenieBitfield = 0x02000000u, // PKLite
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!;
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[0x71u] = 1; // Gender: Male
|
||||||
|
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
|
||||||
|
properties.Ints[0x105u] = 13; // CharacterTitleId
|
||||||
|
properties.Ints[30u] = 5; // AllegianceRank
|
||||||
|
properties.Strings[47u] = "The Empire";
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(
|
||||||
|
Parsed(properties, MinimalCreatureProfile(), success: false)));
|
||||||
|
|
||||||
|
Assert.Equal("Male Aluvian", HeaderText(layout, 0x10000150u));
|
||||||
|
Assert.Equal("War Mage", HeaderText(layout, 0x10000151u));
|
||||||
|
Assert.Equal("Player Killer Lite", HeaderText(layout, 0x10000152u));
|
||||||
|
Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatureResponse_HeaderIdentityElementsUnaffectedByPlayerFix()
|
||||||
|
{
|
||||||
|
// Regression pin (task constraint): the character:false (monster)
|
||||||
|
// path must keep its pre-AS2 behavior for the four header elements
|
||||||
|
// AS2 remapped for players. Pre-fix, 0x10000150/51/52 were never
|
||||||
|
// touched for monsters (stay at their cleared default) and
|
||||||
|
// 0x1000053A was set to string.Empty whenever Success was true —
|
||||||
|
// identical to today's post-fix "gated, nothing gates it open"
|
||||||
|
// result for a bundle with no allegiance data.
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Specter",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
creatureNames: new CreatureDisplayNameResolver(
|
||||||
|
new Dictionary<uint, string> { [77u] = "Ghost" }))!;
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[2u] = 77; // CreatureType — no String 5 / Int 261 marker present.
|
||||||
|
properties.Ints[25u] = 80;
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
||||||
|
|
||||||
|
Assert.Equal("Ghost", HeaderText(layout, AppraisalUiController.CreatureDisplayNameId));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x10000150u));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x10000152u));
|
||||||
|
Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
|
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
|
||||||
{
|
{
|
||||||
|
|
@ -832,7 +1178,8 @@ public sealed class AppraisalUiControllerTests
|
||||||
Func<uint, uint>? resolveComponentIcon = null,
|
Func<uint, uint>? resolveComponentIcon = null,
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
Func<MagicSchool, uint>? magicSkill = null,
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
||||||
|
Func<uint, string?>? resolveCharacterTitle = null)
|
||||||
=> AppraisalUiController.Bind(
|
=> AppraisalUiController.Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
|
|
@ -852,7 +1199,8 @@ public sealed class AppraisalUiControllerTests
|
||||||
resolveComponentIcon,
|
resolveComponentIcon,
|
||||||
spellComponents,
|
spellComponents,
|
||||||
magicSkill,
|
magicSkill,
|
||||||
spellComponentTemplates);
|
spellComponentTemplates,
|
||||||
|
resolveCharacterTitle);
|
||||||
|
|
||||||
private static ItemInteractionController NewInteraction(
|
private static ItemInteractionController NewInteraction(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
|
|
@ -871,13 +1219,14 @@ public sealed class AppraisalUiControllerTests
|
||||||
private static AppraiseInfoParser.Parsed Parsed(
|
private static AppraiseInfoParser.Parsed Parsed(
|
||||||
PropertyBundle properties,
|
PropertyBundle properties,
|
||||||
AppraiseInfoParser.CreatureProfile? creature = null,
|
AppraiseInfoParser.CreatureProfile? creature = null,
|
||||||
uint guid = ObjectId)
|
uint guid = ObjectId,
|
||||||
|
bool success = true)
|
||||||
=> new(
|
=> new(
|
||||||
Guid: guid,
|
Guid: guid,
|
||||||
Flags: creature is null
|
Flags: creature is null
|
||||||
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
||||||
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
|
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
|
||||||
Success: true,
|
Success: success,
|
||||||
Properties: properties,
|
Properties: properties,
|
||||||
SpellBook: [],
|
SpellBook: [],
|
||||||
ArmorProfile: null,
|
ArmorProfile: null,
|
||||||
|
|
@ -889,6 +1238,35 @@ public sealed class AppraisalUiControllerTests
|
||||||
WeaponEnchantments: null,
|
WeaponEnchantments: null,
|
||||||
ResistEnchantments: null);
|
ResistEnchantments: null);
|
||||||
|
|
||||||
|
/// <summary>Minimal <c>CreatureProfile</c> — just enough for
|
||||||
|
/// <see cref="AppraisalUiController.SelectView"/>'s CreatureProfile-not-null
|
||||||
|
/// gate; every attribute is intentionally absent since these tests pin
|
||||||
|
/// the header identity block, not the attribute rows.</summary>
|
||||||
|
private static AppraiseInfoParser.CreatureProfile MinimalCreatureProfile()
|
||||||
|
=> new(
|
||||||
|
Flags: 0,
|
||||||
|
Health: 1u,
|
||||||
|
HealthMax: 1u,
|
||||||
|
Strength: null,
|
||||||
|
Endurance: null,
|
||||||
|
Quickness: null,
|
||||||
|
Coordination: null,
|
||||||
|
Focus: null,
|
||||||
|
Self: null,
|
||||||
|
Stamina: null,
|
||||||
|
Mana: null,
|
||||||
|
StaminaMax: null,
|
||||||
|
ManaMax: null,
|
||||||
|
AttributeHighlights: null,
|
||||||
|
AttributeColors: null);
|
||||||
|
|
||||||
|
private static string HeaderText(ImportedLayout layout, uint elementId)
|
||||||
|
{
|
||||||
|
UiText text = Assert.IsType<UiText>(layout.FindElement(elementId));
|
||||||
|
return string.Join(
|
||||||
|
'\n', text.LinesProvider().Select(line => line.Text));
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertSpellText(
|
private static void AssertSpellText(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
uint elementId,
|
uint elementId,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue