Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).
Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.
GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.
Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).
Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.
Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1793 lines
68 KiB
C#
1793 lines
68 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Spells;
|
|
using AcDream.Content;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Spells;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public sealed class AppraisalUiControllerTests
|
|
{
|
|
private const uint ObjectId = 0x50000001u;
|
|
private static (uint, int, int) NoTexture(uint _) => (0u, 0, 0);
|
|
|
|
[Fact]
|
|
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Atlan Weapon",
|
|
Type = ItemType.MeleeWeapon,
|
|
Value = 1250,
|
|
Burden = 350,
|
|
ContainerId = 0x50000002u,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
|
});
|
|
var sent = new List<uint>();
|
|
var inscriptions = new List<(uint ObjectId, string Text)>();
|
|
var messages = new List<string>();
|
|
using var interaction = NewInteraction(objects, sent);
|
|
var combat = new CombatState();
|
|
int shown = 0;
|
|
int closed = 0;
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
combat,
|
|
inscriptions,
|
|
messages,
|
|
() => shown++,
|
|
() => closed++)!;
|
|
|
|
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[19u] = 1_250;
|
|
properties.Ints[5u] = 350;
|
|
properties.Strings[16u] = "A finely balanced weapon.";
|
|
properties.Strings[7u] = "Remember the fallen.";
|
|
properties.Strings[8u] = "Tester";
|
|
|
|
Assert.True(controller.Apply(Parsed(properties)));
|
|
|
|
Assert.Equal(1, shown);
|
|
Assert.Equal(AppraisalView.Item, controller.ActiveView);
|
|
Assert.Equal(0, interaction.BusyCount);
|
|
UiText title = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.TitleId));
|
|
Assert.Equal("Atlan Weapon", Assert.Single(title.LinesProvider()).Text);
|
|
UiText itemText = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.ItemTextId));
|
|
Assert.Equal(VJustify.Top, itemText.VerticalJustify);
|
|
Assert.Equal(
|
|
[
|
|
new Vector4(1f, 1f, 1f, 1f),
|
|
new Vector4(0f, 1f, 0f, 1f),
|
|
new Vector4(1f, 0f, 0f, 1f),
|
|
],
|
|
itemText.FontColorPalette);
|
|
string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text));
|
|
Assert.Contains("Value: 1,250", report);
|
|
Assert.Contains("Burden: 350", report);
|
|
Assert.Contains("A finely balanced weapon.", report);
|
|
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
|
|
layout.FindElement(AppraisalUiController.ItemScrollbarId));
|
|
Assert.Same(itemText.Scroll, scrollbar.Model);
|
|
UiField inscription = Assert.IsType<UiField>(
|
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
Assert.Contains("Remember the fallen", inscription.Text);
|
|
Assert.True(inscription.Editable);
|
|
UiText signature = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.SignatureTextId));
|
|
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
|
Assert.Empty(inscriptions);
|
|
Assert.Empty(messages);
|
|
|
|
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
|
|
Assert.Equal(1, closed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemResponse_TitleUsesRetailMaterialDecoratedAppropriateName()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Steel Toed Boots",
|
|
Type = ItemType.Clothing,
|
|
MaterialType = 77u,
|
|
});
|
|
using var interaction = NewInteraction(objects, []);
|
|
var names = new RetailAppraisalNameResolver(
|
|
new Dictionary<uint, string> { [77u] = "Reed Shark Hide" },
|
|
new CreatureDisplayNameResolver(new Dictionary<uint, string>()));
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => { },
|
|
() => { },
|
|
itemNames: names)!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
|
|
|
|
UiText title = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.TitleId));
|
|
Assert.Equal(
|
|
"Reed Shark Hide Steel Toed Boots",
|
|
Assert.Single(title.LinesProvider()).Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Drudge Slinker",
|
|
Type = ItemType.Creature,
|
|
});
|
|
var sent = new List<uint>();
|
|
using var interaction = NewInteraction(objects, sent);
|
|
var combat = new CombatState();
|
|
int shown = 0;
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
combat,
|
|
[],
|
|
[],
|
|
() => shown++,
|
|
() => { })!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[25u] = 12;
|
|
var creature = new AppraiseInfoParser.CreatureProfile(
|
|
Flags: 0,
|
|
Health: 80,
|
|
HealthMax: 100,
|
|
Strength: null,
|
|
Endurance: null,
|
|
Quickness: null,
|
|
Coordination: null,
|
|
Focus: null,
|
|
Self: null,
|
|
Stamina: null,
|
|
Mana: null,
|
|
StaminaMax: null,
|
|
ManaMax: null,
|
|
AttributeHighlights: null,
|
|
AttributeColors: null);
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, creature)));
|
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
|
Assert.Equal(1, shown);
|
|
controller.OnShown();
|
|
Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
|
|
Assert.True(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
|
|
|
|
combat.SetCombatMode(CombatMode.Melee);
|
|
controller.Tick(0.74);
|
|
Assert.Single(sent);
|
|
controller.Tick(0.01);
|
|
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
|
|
Assert.Equal(0, interaction.BusyCount);
|
|
|
|
controller.OnHidden();
|
|
controller.Tick(0.75);
|
|
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, creature)));
|
|
Assert.Equal(1, shown);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreatureResponse_UsesRetailHeaderAndNineOrderedTemplateRows()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Specter",
|
|
Type = ItemType.Creature,
|
|
});
|
|
using var interaction = NewInteraction(objects, []);
|
|
UiText characterLabel = Assert.IsType<UiText>(
|
|
layout.FindElement(0x1000014Au));
|
|
UiText levelLabel = Assert.IsType<UiText>(
|
|
layout.FindElement(0x1000014Bu));
|
|
characterLabel.LinesProvider = () =>
|
|
[new UiText.Line("Character", characterLabel.DefaultColor)];
|
|
levelLabel.LinesProvider = () =>
|
|
[new UiText.Line("Level", levelLabel.DefaultColor)];
|
|
var templates = new CreatureAppraisalRowTemplateFactory(
|
|
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
|
NoTexture,
|
|
defaultFont: null);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => { },
|
|
() => { },
|
|
templates,
|
|
new CreatureDisplayNameResolver(
|
|
new Dictionary<uint, string> { [77u] = "Ghost" }))!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[2u] = 77;
|
|
properties.Ints[25u] = 80;
|
|
properties.Ints[0x133u] = 35;
|
|
properties.Ints[0x13Au] = 4;
|
|
var profile = new AppraiseInfoParser.CreatureProfile(
|
|
Flags: 0x08u,
|
|
Health: 295u,
|
|
HealthMax: 295u,
|
|
Strength: 120u,
|
|
Endurance: 190u,
|
|
Quickness: 190u,
|
|
Coordination: 190u,
|
|
Focus: 330u,
|
|
Self: 350u,
|
|
Stamina: 190u,
|
|
Mana: 550u,
|
|
StaminaMax: 190u,
|
|
ManaMax: 550u,
|
|
AttributeHighlights: (ushort)0,
|
|
AttributeColors: (ushort)0);
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, profile)));
|
|
|
|
Assert.Equal(
|
|
"Character",
|
|
Assert.Single(characterLabel.LinesProvider()).Text);
|
|
Assert.Equal("Level", Assert.Single(levelLabel.LinesProvider()).Text);
|
|
Assert.Equal(
|
|
"80",
|
|
Assert.Single(((UiText)layout.FindElement(
|
|
AppraisalUiController.CreatureLevelValueId)!)
|
|
.LinesProvider()).Text);
|
|
Assert.Equal(
|
|
"Ghost",
|
|
Assert.Single(((UiText)layout.FindElement(
|
|
AppraisalUiController.CreatureDisplayNameId)!)
|
|
.LinesProvider()).Text);
|
|
|
|
UiElement host = layout.FindElement(
|
|
AppraisalUiController.CreatureStatsListId)!;
|
|
UiElement creaturePanel = layout.FindElement(
|
|
AppraisalUiController.CreaturePanelId)!;
|
|
UiViewport viewport = Assert.IsType<UiViewport>(
|
|
layout.FindElement(AppraisalUiController.CreatureViewportId));
|
|
UiItemList background = Assert.Single(
|
|
host.Children.OfType<UiItemList>());
|
|
UiItemList list = Assert.Single(
|
|
creaturePanel.Children.OfType<UiItemList>(),
|
|
candidate => candidate.Top == host.Top);
|
|
Assert.Equal(0f, background.Left);
|
|
Assert.Equal(CreatureAppraisalLayeredList.TextInset, list.Left);
|
|
Assert.True(host.ZOrder < viewport.ZOrder);
|
|
Assert.True(viewport.ZOrder < list.ZOrder);
|
|
Assert.Same(background.Scroll, list.Scroll);
|
|
Assert.Equal(9, list.GetNumUIItems());
|
|
string[] labels = new string[9];
|
|
string[] values = new string[9];
|
|
for (int index = 0; index < 9; index++)
|
|
{
|
|
UiTemplateListSlot row =
|
|
Assert.IsType<UiTemplateListSlot>(list.GetItem(index));
|
|
labels[index] = Assert.Single(
|
|
((UiText)row.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.LabelId)!)
|
|
.LinesProvider()).Text;
|
|
values[index] = Assert.Single(
|
|
((UiText)row.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.ValueId)!)
|
|
.LinesProvider()).Text;
|
|
}
|
|
Assert.Equal(
|
|
[
|
|
"Strength", "Endurance", "Coordination", "Quickness",
|
|
"Focus", "Self", "Health", "Stamina", "Mana",
|
|
],
|
|
labels);
|
|
Assert.Equal(
|
|
[
|
|
"120", "190", "190", "190", "330", "350",
|
|
"295/295 (100 %)", "190/190", "550/550",
|
|
],
|
|
values);
|
|
|
|
UiElement extraHost = layout.FindElement(
|
|
AppraisalUiController.CreatureExtraListId)!;
|
|
UiItemList extra = Assert.Single(
|
|
creaturePanel.Children.OfType<UiItemList>(),
|
|
candidate => candidate.Top == extraHost.Top);
|
|
Assert.Equal(3, extra.GetNumUIItems());
|
|
UiTemplateListSlot rating =
|
|
Assert.IsType<UiTemplateListSlot>(extra.GetItem(1));
|
|
Assert.Equal(
|
|
"Dmg/CritDmg",
|
|
Assert.Single(((UiText)rating.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.LabelId)!)
|
|
.LinesProvider()).Text);
|
|
Assert.Equal(
|
|
"Rating: 35/4",
|
|
Assert.Single(((UiText)rating.Content.FindElement(
|
|
CreatureAppraisalRowTemplateFactory.ValueId)!)
|
|
.LinesProvider()).Text);
|
|
Assert.True(extraHost.ZOrder < viewport.ZOrder);
|
|
Assert.True(viewport.ZOrder < extra.ZOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void VisibleExaminationFollowsSelectionAcrossSubviewsAndClosesOnClear()
|
|
{
|
|
const uint otherObjectId = 0x50000003u;
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Drudge",
|
|
Type = ItemType.Creature,
|
|
});
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = otherObjectId,
|
|
Name = "Sword",
|
|
Type = ItemType.MeleeWeapon,
|
|
});
|
|
var selection = new SelectionState();
|
|
var sent = new List<uint>();
|
|
int closed = 0;
|
|
using var interaction = NewInteraction(objects, sent);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => { },
|
|
() => closed++,
|
|
selection: selection)!;
|
|
|
|
selection.Select(ObjectId, SelectionChangeSource.World);
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
Assert.True(controller.Apply(Parsed(
|
|
new PropertyBundle(),
|
|
new AppraiseInfoParser.CreatureProfile(
|
|
Flags: 0u,
|
|
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))));
|
|
controller.OnShown();
|
|
|
|
selection.Select(otherObjectId, SelectionChangeSource.Inventory);
|
|
Assert.Equal(new[] { ObjectId, otherObjectId }, sent);
|
|
Assert.True(controller.Apply(Parsed(
|
|
new PropertyBundle(),
|
|
guid: otherObjectId)));
|
|
Assert.Equal(AppraisalView.Item, controller.ActiveView);
|
|
|
|
selection.Clear(SelectionChangeSource.World);
|
|
Assert.Equal(1, closed);
|
|
|
|
controller.OnHidden();
|
|
selection.Select(ObjectId, SelectionChangeSource.World);
|
|
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));
|
|
}
|
|
|
|
// ── Campaign AS slice AS5: title-bar allegiance rank-title prefix ─────
|
|
// Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md
|
|
// §2a's title-bar row, gap G9, ruling R8. Retires register row AP-109.
|
|
|
|
[Fact]
|
|
public void CharacterResponse_TitleBarPrefixesAllegianceRankTitle()
|
|
{
|
|
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] = "Template"; // Character-view marker
|
|
properties.Ints[0x1Eu] = 3; // AllegianceRank
|
|
properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
|
|
properties.Ints[0x71u] = 2; // Gender: Female
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
|
// Aluvian female rank 3 = "Baroness" (AllegianceRankTitleTableTests
|
|
// pins the table itself); GetFullName's single-space separator.
|
|
Assert.Equal("Baroness Dww", HeaderText(layout, AppraisalUiController.TitleId));
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterResponse_TitleBarPlainNameWhenRankAbsent()
|
|
{
|
|
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] = "Template"; // Character-view marker, no AllegianceRank.
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
|
Assert.Equal("Dww", HeaderText(layout, AppraisalUiController.TitleId));
|
|
}
|
|
|
|
/// <summary>Regression pin: <c>CreatureExamineUI</c> (monsters) never
|
|
/// calls <c>AllegianceData::GetFullName</c> — the overwrite is scoped to
|
|
/// the <c>character</c> branch of <c>ApplyCreature</c> only, even when
|
|
/// rank/heritage/gender-shaped properties happen to be present.</summary>
|
|
[Fact]
|
|
public void CreatureResponse_TitleBarNeverGetsAllegianceRankPrefix()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Drudge",
|
|
Type = ItemType.Creature,
|
|
});
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => { },
|
|
() => { })!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var properties = new PropertyBundle();
|
|
// No String 5 / Int 0x105 -> monster path, NOT the character view.
|
|
properties.Ints[0x1Eu] = 3;
|
|
properties.Ints[0xBCu] = 1;
|
|
properties.Ints[0x71u] = 2;
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
|
Assert.Equal("Drudge", HeaderText(layout, AppraisalUiController.TitleId));
|
|
}
|
|
|
|
[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]
|
|
public void FailedMonsterAssess_DoesNotEmitInventedAssessmentIncompleteLiteral()
|
|
{
|
|
// Regression pin (review F14): pre-AS2, a FAILED monster assess
|
|
// (character:false — no String 5 Template, no Int 0x105 marker) was
|
|
// the exact case that set 0x1000053A to the invented, zero-retail-
|
|
// provenance "Assessment incomplete" literal. The monster branch of
|
|
// ApplyCreature never composes 0x1000053A at all (see
|
|
// CreatureResponse_HeaderIdentityElementsUnaffectedByPlayerFix
|
|
// above), so the element must stay cleared/empty here too, success
|
|
// or not.
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Drudge",
|
|
Type = ItemType.Creature,
|
|
});
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => { },
|
|
() => { },
|
|
creatureNames: new CreatureDisplayNameResolver(
|
|
new Dictionary<uint, string> { [11u] = "Drudge" }))!;
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[2u] = 11; // CreatureType — no String 5 / Int 261 marker present.
|
|
|
|
Assert.True(controller.Apply(
|
|
Parsed(properties, MinimalCreatureProfile(), success: false)));
|
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
|
|
|
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 firstArmorLevels = new AppraiseInfoParser.ArmorLevel(
|
|
Head: 50, Chest: 60, Abdomen: 70,
|
|
UpperArm: 80, LowerArm: 90, Hand: 100,
|
|
UpperLeg: 110, LowerLeg: 120, Foot: 130);
|
|
|
|
Assert.True(controller.Apply(Parsed(
|
|
properties, MinimalCreatureProfile(), armorLevels: firstArmorLevels)));
|
|
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);
|
|
|
|
// AS3 NIT 11: the refresh brings back a GENUINELY DIFFERENT response
|
|
// (e.g. gear swapped mid-fight) — proving the extras list re-renders
|
|
// from the fresh data rather than replaying a cached copy of the
|
|
// first response.
|
|
var secondArmorLevels = new AppraiseInfoParser.ArmorLevel(
|
|
Head: 51, Chest: 61, Abdomen: 71,
|
|
UpperArm: 81, LowerArm: 91, Hand: 101,
|
|
UpperLeg: 111, LowerLeg: 121, Foot: 131);
|
|
Assert.True(controller.Apply(Parsed(
|
|
properties, MinimalCreatureProfile(), armorLevels: secondArmorLevels)));
|
|
|
|
UiItemList extraAfterSecond = CreatureExtraList(layout);
|
|
Assert.Equal(5, extraAfterSecond.GetNumUIItems());
|
|
Assert.Equal(
|
|
("Head/Chest/Groin", "AL: 51/61/71"), ExtraRow(extraAfterSecond, 1));
|
|
Assert.Equal(
|
|
("Bicep/Wrist/Hand", "AL: 81/91/101"), ExtraRow(extraAfterSecond, 2));
|
|
Assert.Equal(
|
|
("Thigh/Shin/Foot", "AL: 111/121/131"), ExtraRow(extraAfterSecond, 3));
|
|
|
|
// A THIRD response carrying no ArmorLevels blob at all (e.g. a
|
|
// response that simply didn't set the flag) clears the trio
|
|
// entirely rather than leaving the second response's rows stuck
|
|
// on screen.
|
|
Assert.True(controller.Apply(Parsed(
|
|
properties, MinimalCreatureProfile(), armorLevels: null)));
|
|
|
|
UiItemList extraAfterThird = CreatureExtraList(layout);
|
|
Assert.Equal(1, extraAfterThird.GetNumUIItems());
|
|
Assert.Equal(
|
|
("* = Unenchantable", string.Empty), ExtraRow(extraAfterThird, 0));
|
|
}
|
|
|
|
// ── Campaign AS slice AS4: society/allegiance/fellowship + configurable
|
|
// extras (G6/G7/G8). Real LayoutDesc/template binding through the
|
|
// localFactionBits seam (AppraisalRuntimeBindings.LocalFactionBits in
|
|
// production), matching the AS2/AS3 controller-level pattern.
|
|
|
|
[Fact]
|
|
public void CharacterResponse_SocietyAllegianceAndFellowshipRenderThroughRealBinding()
|
|
{
|
|
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,
|
|
// The seam AppraisalRuntimeBindings.LocalFactionBits threads in
|
|
// production: the local player shares the target's Celestial
|
|
// Hand bit.
|
|
localFactionBits: () => 0x1)!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var properties = new PropertyBundle();
|
|
properties.Strings[5u] = "Template"; // Character-view marker
|
|
properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand
|
|
properties.Ints[287u] = 50; // society rank -> Initiate band
|
|
properties.Ints[30u] = 5; // AllegianceRank >= 1
|
|
properties.Strings[21u] = "Monarch Title"; // no PatronsTitle -> Monarch-only row
|
|
properties.Strings[10u] = "Fellows";
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
|
|
|
UiItemList extra = CreatureExtraList(layout);
|
|
Assert.Equal(4, extra.GetNumUIItems());
|
|
Assert.Equal(
|
|
("Society:", "Celestial Hand ~ Initiate"), ExtraRow(extra, 0));
|
|
Assert.Equal(("Monarch:", "Monarch Title"), ExtraRow(extra, 1));
|
|
Assert.Equal(("Fellowship:", "Fellows"), ExtraRow(extra, 2));
|
|
Assert.Equal(
|
|
("* = Unenchantable", string.Empty), ExtraRow(extra, 3));
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterResponse_LocalFactionBitsDefaultsToFactionlessWhenBindingSuppliesNone()
|
|
{
|
|
// No localFactionBits argument -> AppraisalUiController's own
|
|
// fallback (() => 0), exactly like a caller that never wires the
|
|
// binding (matches R7-style defensive defaults elsewhere in this
|
|
// controller).
|
|
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";
|
|
properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand
|
|
|
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
|
|
|
UiItemList extra = CreatureExtraList(layout);
|
|
// Row still renders (Society doesn't gate on the local player); it
|
|
// just gets no color because the local player has no matching bit.
|
|
// No rank property was set, so there is no band suffix (rank 0 is
|
|
// outside every band).
|
|
Assert.Equal(
|
|
("Society:", "Celestial Hand"), ExtraRow(extra, 0));
|
|
}
|
|
|
|
[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, and
|
|
// so must AS4's society/allegiance/configurable-extras properties —
|
|
// a real ACE response can carry those for a non-attackable monster
|
|
// target too (ground truth §3), but CreatureExamineUI::
|
|
// SetAppraiseInfo @0x004B3FF0 never reads any of them.
|
|
var armorLevels = 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[281u] = 0x1; // Faction1Bits
|
|
properties.Ints[30u] = 5; // AllegianceRank
|
|
properties.Strings[21u] = "Monarch Title";
|
|
properties.Strings[10u] = "Fellows";
|
|
properties.Ints[43u] = 2; // NumDeaths
|
|
|
|
Assert.True(controller.Apply(Parsed(
|
|
properties,
|
|
MinimalCreatureProfile(),
|
|
armorLevels: armorLevels)));
|
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
|
|
|
UiItemList extra = CreatureExtraList(layout);
|
|
Assert.Equal(0, extra.GetNumUIItems());
|
|
}
|
|
|
|
[Fact]
|
|
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => throw new InvalidOperationException("must not show"),
|
|
() => { })!;
|
|
|
|
Assert.False(controller.Apply(Parsed(new PropertyBundle())));
|
|
Assert.Equal(0u, controller.CurrentObjectId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyOwnedInscription_FocusEditAndBlur_SendsRetailTransactionOnce()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Sword",
|
|
Type = ItemType.MeleeWeapon,
|
|
ContainerId = 0x50000002u,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
|
});
|
|
var inscriptions = new List<(uint ObjectId, string Text)>();
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
inscriptions,
|
|
[],
|
|
() => { },
|
|
() => { })!;
|
|
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
|
|
|
|
UiField field = Assert.IsType<UiField>(
|
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
UiText signature = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.SignatureTextId));
|
|
Assert.True(field.Editable);
|
|
Assert.Equal("<Inscribe here>", field.Text);
|
|
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
|
|
Assert.Equal(string.Empty, field.Text);
|
|
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
|
foreach (char c in "For glory")
|
|
field.InsertChar(c);
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
|
|
|
Assert.Equal(
|
|
new[] { (ObjectId, "For glory") },
|
|
inscriptions);
|
|
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
|
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
|
Assert.Single(inscriptions);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExistingInscription_ClearOnBlur_SendsEmptyAndRestoresPlaceholder()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Sword",
|
|
Type = ItemType.MeleeWeapon,
|
|
ContainerId = 0x50000002u,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
|
});
|
|
var inscriptions = new List<(uint ObjectId, string Text)>();
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
inscriptions,
|
|
[],
|
|
() => { },
|
|
() => { })!;
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var properties = new PropertyBundle();
|
|
properties.Strings[7u] = "Old words";
|
|
properties.Strings[8u] = "Tester";
|
|
Assert.True(controller.Apply(Parsed(properties)));
|
|
|
|
UiField field = Assert.IsType<UiField>(
|
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
UiText signature = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.SignatureTextId));
|
|
field.SetText(string.Empty);
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
|
|
|
Assert.Equal(new[] { (ObjectId, string.Empty) }, inscriptions);
|
|
Assert.Equal("<Inscribe here>", field.Text);
|
|
Assert.Equal(string.Empty, Assert.Single(signature.LinesProvider()).Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void InscriptionPermissionMessages_MatchRetail()
|
|
{
|
|
ImportedLayout otherScribeLayout = FixtureLoader.LoadExamination();
|
|
var otherScribeObjects = new ClientObjectTable();
|
|
otherScribeObjects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Sword",
|
|
ContainerId = 0x50000002u,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
|
});
|
|
var messages = new List<string>();
|
|
using var otherInteraction = NewInteraction(otherScribeObjects, []);
|
|
using AppraisalUiController otherController = Bind(
|
|
otherScribeLayout,
|
|
otherScribeObjects,
|
|
otherInteraction,
|
|
new CombatState(),
|
|
[],
|
|
messages,
|
|
() => { },
|
|
() => { })!;
|
|
otherInteraction.ExamineSelectedOrEnterMode(ObjectId);
|
|
var otherProperties = new PropertyBundle();
|
|
otherProperties.Strings[7u] = "Hands off";
|
|
otherProperties.Strings[8u] = "Other";
|
|
Assert.True(otherController.Apply(Parsed(otherProperties)));
|
|
UiField otherField = Assert.IsType<UiField>(
|
|
otherScribeLayout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
Assert.False(otherField.Editable);
|
|
otherField.OnEvent(new UiEvent(
|
|
otherField.EventId,
|
|
otherField,
|
|
UiEventType.Click));
|
|
Assert.Equal("Only Other can change the inscription", Assert.Single(messages));
|
|
|
|
ImportedLayout unownedLayout = FixtureLoader.LoadExamination();
|
|
var unownedObjects = new ClientObjectTable();
|
|
unownedObjects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Sword",
|
|
ContainerId = 0x60000000u,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
|
});
|
|
messages.Clear();
|
|
using var unownedInteraction = NewInteraction(unownedObjects, []);
|
|
using AppraisalUiController unownedController = Bind(
|
|
unownedLayout,
|
|
unownedObjects,
|
|
unownedInteraction,
|
|
new CombatState(),
|
|
[],
|
|
messages,
|
|
() => { },
|
|
() => { })!;
|
|
unownedInteraction.ExamineSelectedOrEnterMode(ObjectId);
|
|
Assert.True(unownedController.Apply(Parsed(new PropertyBundle())));
|
|
UiField unownedField = Assert.IsType<UiField>(
|
|
unownedLayout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
unownedField.OnEvent(new UiEvent(
|
|
unownedField.EventId,
|
|
unownedField,
|
|
UiEventType.Click));
|
|
Assert.Equal(
|
|
"Item must be in your inventory to inscribe.",
|
|
Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonInscribableItem_HidesEditorAndBackgroundReportsRetailMessage()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Component",
|
|
ContainerId = 0x50000002u,
|
|
});
|
|
var messages = new List<string>();
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
messages,
|
|
() => { },
|
|
() => { })!;
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
|
|
|
|
UiField field = Assert.IsType<UiField>(
|
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
UiText signature = Assert.IsType<UiText>(
|
|
layout.FindElement(AppraisalUiController.SignatureTextId));
|
|
Assert.False(field.Visible);
|
|
Assert.False(signature.Visible);
|
|
|
|
UiDatElement background = Assert.IsType<UiDatElement>(
|
|
layout.FindElement(AppraisalUiController.InscriptionBackgroundId));
|
|
background.OnClick!.Invoke();
|
|
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void HookProfileControlsVisibilityButPublicFlagStillControlsEditing()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Hooked Decoration",
|
|
ContainerId = 0x50000002u,
|
|
HookItemTypes = (uint)ItemType.Misc,
|
|
HookType = 1u,
|
|
});
|
|
var messages = new List<string>();
|
|
using var interaction = NewInteraction(objects, []);
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
messages,
|
|
() => { },
|
|
() => { })!;
|
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
|
|
|
AppraiseInfoParser.Parsed appraisal = Parsed(new PropertyBundle()) with
|
|
{
|
|
Flags = AppraiseInfoParser.IdentifyResponseFlags.HookProfile,
|
|
HookProfile = new AppraiseInfoParser.HookProfile(
|
|
Flags: 1u,
|
|
ValidLocations: 0u,
|
|
AmmoType: 0u),
|
|
};
|
|
Assert.True(controller.Apply(appraisal));
|
|
|
|
UiField field = Assert.IsType<UiField>(
|
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
|
Assert.True(field.Visible);
|
|
Assert.False(field.Editable);
|
|
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.Click));
|
|
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
|
|
}
|
|
|
|
[Fact]
|
|
public void Examination_IsAnIndependentFloatyWindow_NotSharedMainPanelContent()
|
|
{
|
|
Assert.False(
|
|
RetailPanelCatalog.TryGetPanelId(WindowNames.Examination, out _));
|
|
Assert.DoesNotContain(
|
|
RetailPanelCatalog.MountedPanels,
|
|
mounted => mounted.WindowName == WindowNames.Examination);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpellExamination_UsesAuthoredLocalSubviewWithoutSelectingOrAppraisingSpellId()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ObjectId,
|
|
Name = "Selected Drudge",
|
|
Type = ItemType.Creature,
|
|
});
|
|
var sent = new List<uint>();
|
|
using var interaction = NewInteraction(objects, sent);
|
|
var selection = new SelectionState();
|
|
selection.Select(ObjectId, SelectionChangeSource.World);
|
|
SpellMetadata metadata = ExaminedSpell();
|
|
var spellbook = new Spellbook(SpellTable.Create([metadata]));
|
|
var components = new[]
|
|
{
|
|
new SpellExamineComponent(
|
|
10u,
|
|
new SpellComponentDescriptor(100u, "Lead Scarab", 0u, 0x06000010u),
|
|
Owned: true),
|
|
new SpellExamineComponent(
|
|
11u,
|
|
new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u),
|
|
Owned: false),
|
|
};
|
|
var componentTemplate = new SpellExamineComponentTemplateFactory(
|
|
new ElementInfo
|
|
{
|
|
Id = SpellExamineComponentTemplateFactory.TemplateId,
|
|
Type = 3,
|
|
Width = 20,
|
|
Height = 20,
|
|
},
|
|
NoTexture,
|
|
defaultFont: null);
|
|
var resolvedComponentIcons = new List<uint>();
|
|
int shown = 0;
|
|
using AppraisalUiController controller = Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
new CombatState(),
|
|
[],
|
|
[],
|
|
() => shown++,
|
|
() => { },
|
|
selection: selection,
|
|
spellbook: spellbook,
|
|
resolveSpellIcon: id => id + 1_000u,
|
|
resolveComponentIcon: did =>
|
|
{
|
|
resolvedComponentIcons.Add(did);
|
|
return did + 2_000u;
|
|
},
|
|
spellComponents: _ => components,
|
|
magicSkill: _ => 200u,
|
|
spellComponentTemplates: componentTemplate)!;
|
|
|
|
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
|
|
Assert.Equal(1, interaction.BusyCount);
|
|
|
|
Assert.True(controller.ExamineSpell(metadata.SpellId));
|
|
|
|
Assert.Equal(AppraisalView.Spell, controller.ActiveView);
|
|
Assert.Equal(1, shown);
|
|
Assert.Equal(ObjectId, selection.SelectedObjectId);
|
|
Assert.Equal(new uint[] { ObjectId, 0u }, sent);
|
|
Assert.Equal(0, interaction.BusyCount);
|
|
Assert.Equal(0u, controller.CurrentObjectId);
|
|
Assert.True(layout.FindElement(AppraisalUiController.SpellPanelId)!.Visible);
|
|
Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
|
|
Assert.False(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
|
|
|
|
AssertSpellText(
|
|
layout,
|
|
AppraisalUiController.TitleId,
|
|
"Incantation of Test");
|
|
AssertSpellText(
|
|
layout,
|
|
AppraisalUiController.SpellSchoolTextId,
|
|
"School: War Magic");
|
|
AssertSpellText(
|
|
layout,
|
|
AppraisalUiController.SpellManaTextId,
|
|
"Mana: 50 + 14 per target");
|
|
AssertSpellText(
|
|
layout,
|
|
AppraisalUiController.SpellDurationTextId,
|
|
"Duration: 1 min.");
|
|
AssertSpellText(
|
|
layout,
|
|
AppraisalUiController.SpellRangeTextId,
|
|
"Range: 82.0 yds.");
|
|
string display = string.Join(
|
|
'\n',
|
|
((UiText)layout.FindElement(
|
|
AppraisalUiController.SpellDisplayTextId)!)
|
|
.LinesProvider()
|
|
.Select(line => line.Text));
|
|
Assert.Contains("A projected retail spell.", display);
|
|
Assert.Contains("COMPONENTS:", display);
|
|
Assert.Contains("Lead Scarab", display);
|
|
Assert.Contains("Malar Herb", display);
|
|
|
|
UiElement iconHost = layout.FindElement(
|
|
AppraisalUiController.SpellIconId)!;
|
|
UiTextureElement icon = Assert.Single(
|
|
iconHost.Children.OfType<UiTextureElement>());
|
|
Assert.Equal(metadata.SpellId + 1_000u, icon.Texture);
|
|
Assert.Equal(
|
|
new uint[] { 0x06000010u, 0x06000011u },
|
|
resolvedComponentIcons);
|
|
UiElement formula = layout.FindElement(
|
|
AppraisalUiController.SpellFormulaListId)!;
|
|
Assert.Equal(
|
|
new uint[] { 0x06000010u + 2_000u, 0x06000011u + 2_000u },
|
|
formula.Children
|
|
.OfType<UiDatElement>()
|
|
.Select(cell => cell.RuntimeImageTexture!.Value));
|
|
}
|
|
|
|
private static AppraisalUiController? Bind(
|
|
ImportedLayout layout,
|
|
ClientObjectTable objects,
|
|
ItemInteractionController interaction,
|
|
CombatState combat,
|
|
List<(uint ObjectId, string Text)> inscriptions,
|
|
List<string> messages,
|
|
Action show,
|
|
Action close,
|
|
CreatureAppraisalRowTemplateFactory? creatureRows = null,
|
|
CreatureDisplayNameResolver? creatureNames = null,
|
|
SelectionState? selection = null,
|
|
RetailAppraisalNameResolver? itemNames = null,
|
|
Spellbook? spellbook = null,
|
|
Func<uint, uint>? resolveSpellIcon = null,
|
|
Func<uint, uint>? resolveComponentIcon = null,
|
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
|
Func<MagicSchool, uint>? magicSkill = null,
|
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
|
Func<uint, string?>? resolveCharacterTitle = null,
|
|
Func<int>? localFactionBits = null)
|
|
=> AppraisalUiController.Bind(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
selection ?? new SelectionState(),
|
|
combat,
|
|
spellbook ?? new Spellbook(),
|
|
() => "Tester",
|
|
(objectId, text) => inscriptions.Add((objectId, text)),
|
|
messages.Add,
|
|
show,
|
|
close,
|
|
creatureRows,
|
|
creatureNames,
|
|
itemNames,
|
|
resolveSpellIcon,
|
|
resolveComponentIcon,
|
|
spellComponents,
|
|
magicSkill,
|
|
spellComponentTemplates,
|
|
resolveCharacterTitle,
|
|
localFactionBits);
|
|
|
|
private static ItemInteractionController NewInteraction(
|
|
ClientObjectTable objects,
|
|
List<uint> sent)
|
|
=> new(
|
|
objects,
|
|
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
|
|
new InteractionState(),
|
|
playerGuid: () => 0x50000002u,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null,
|
|
sendExamine: sent.Add);
|
|
|
|
private static AppraiseInfoParser.Parsed Parsed(
|
|
PropertyBundle properties,
|
|
AppraiseInfoParser.CreatureProfile? creature = null,
|
|
uint guid = ObjectId,
|
|
bool success = true,
|
|
AppraiseInfoParser.ArmorLevel? armorLevels = null)
|
|
=> new(
|
|
Guid: guid,
|
|
Flags: (creature is null
|
|
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
|
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile)
|
|
| (armorLevels is null
|
|
? AppraiseInfoParser.IdentifyResponseFlags.None
|
|
: AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels),
|
|
Success: success,
|
|
Properties: properties,
|
|
SpellBook: [],
|
|
ArmorProfile: null,
|
|
CreatureProfile: creature,
|
|
WeaponProfile: null,
|
|
HookProfile: null,
|
|
ArmorLevels: armorLevels,
|
|
ArmorEnchantments: null,
|
|
WeaponEnchantments: 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 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,
|
|
string expected)
|
|
{
|
|
UiText text = Assert.IsType<UiText>(layout.FindElement(elementId));
|
|
Assert.Equal(
|
|
expected,
|
|
string.Join('\n', text.LinesProvider().Select(line => line.Text)));
|
|
}
|
|
|
|
private static SpellMetadata ExaminedSpell()
|
|
=> new(
|
|
SpellId: 42u,
|
|
Name: "Incantation of Test",
|
|
School: "War Magic",
|
|
Family: 1u,
|
|
IconId: 0x06001234u,
|
|
SpellWords: "Malar Aether",
|
|
Duration: 90f,
|
|
ManaCost: 50,
|
|
IsDebuff: false,
|
|
IsFellowship: false,
|
|
Description: "A projected retail spell.",
|
|
SortKey: 0,
|
|
Difficulty: 0,
|
|
Flags: 0u,
|
|
Generation: 6,
|
|
IsFastWindup: false,
|
|
IsOffensive: true,
|
|
IsUntargeted: false,
|
|
Speed: 0f,
|
|
CasterEffect: 0u,
|
|
TargetEffect: 0u,
|
|
TargetMask: 0u,
|
|
SpellType: 0)
|
|
{
|
|
SchoolId = MagicSchool.WarMagic,
|
|
BaseRangeConstant = 40f,
|
|
BaseRangeModifier = 0.25f,
|
|
ManaModifier = 14u,
|
|
FormulaComponents = [10u, 11u],
|
|
};
|
|
}
|