Ports the last three retail extras-list row families from CharExamineUI:: SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing the character-path extras list end to end (AS2 header + AS3 armor-level/ ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and pinned by a controller-level regression test. Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281 (Faction1Bits) being PRESENT — a literal reading of InqInt's found/not- found return, not the ground-truth doc's informal "!= 0" value test. Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 -> "???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood) comes straight off the decompiled branches. Rank-band suffix boundaries (1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord / 1001-1500 Master, no suffix outside every band) read directly off the decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the ground-truth table exactly. Color rule: green when the LOCAL player shares the target's selected bit (checked first, so extra local bits don't override a match), red when local has a different bit but not the target's, normal when local has no society bits; the unrecognized "???" branch never gets a color (retail's ebx_3 stays at its zero initializer). The local player's own Faction1Bits comes from a new pure `localFactionBits` parameter on BuildExtra — the composer never reads state directly. Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's header AllegianceName binding already consumes. Four arms in retail's exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped "%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present + PatronsTitle (Str 35) absent -> "Monarch:" only; both present and ordinally equal -> one "Monarch/Patron:" row; both present and different -> "Monarch:" then "Patron:". Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship (Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125 Age via the ALREADY-PORTED RetailDurationText.Format — the same ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at @0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with the SAME "Deaths:" label, verified in the decomp), Titles Earned (Int 262). Each row gates independently on its own property being PRESENT (server already strips these per the target's visibility options per ground truth §3) — no client-side option or success gating added. No spacers separate these seven rows, matching the decomp exactly. Seam: AppraisalUiController takes a new `Func<int> _localFactionBits` dependency (per-call, never captured once — the secure-trade deferred-Func lesson), invoked only on the character path. AppraisalRuntimeBindings gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from `d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState instance CharacterSheetProvider already reads from, no new state path. AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now applies a SECOND response with different armor-level values (proving the refresh re-renders from fresh data) then a THIRD with armorLevels: null (proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the full authored row order with the @0x004b5d7d legend anchor. The test Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags whenever armorLevels is supplied (realism sub-nit), applying uniformly to every existing armor-level test in the file. Ground-truth doc imprecisions found while verifying against the decomp directly: (1) the Society gate is presence-of-property, not value != 0 — InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing describes the common case but not the literal branch condition; (2) the Society color rule's bit-priority (same-bit match checked before the other-bits check) wasn't spelled out in the summary table, only "same/ different/none" — confirmed exact by reading all three branches (@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found. Register: docs/architecture/retail-divergence-register.md row AP-110 retires the "exhaustive character detail regions" clause from its still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4 together closed the character-path extras list); the row's other residuals (item-object preview, effective shield projection, cooldown- remaining, augmentation-cost StringInfo, creature FontInfo-list selection) are untouched. Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society gate/bit-priority/band-boundaries/color-vs-local-faction, the full allegiance cascade incl. follower singular/plural/clamp, each configurable extra present/absent, "Has never died", monster-path regression, one complete ordering-pin snapshot) plus 3 new controller- level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/ template binding (localFactionBits seam wired end to end, its default fallback, and the strengthened monster-path regression). Hermetic AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410 passed across all projects, 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1684 lines
64 KiB
C#
1684 lines
64 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));
|
|
}
|
|
|
|
[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],
|
|
};
|
|
}
|