fix(ui): select and examine favorite spells like retail

This commit is contained in:
Erik 2026-07-24 06:47:54 +02:00
parent 043ab10b3c
commit 3e31b0ac70
23 changed files with 974 additions and 34 deletions

View file

@ -165,6 +165,39 @@ public sealed class SpellComponentRequirementServiceTests
Assert.False(service.HasRequiredComponents(50u));
}
[Fact]
public void ExaminationFormulaPreservesOrderAndReportsNestedOwnership()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
ContainerId = 1u,
Type = ItemType.Container,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 2u,
});
var service = Service(
objects,
[10u, 11u, 10u],
new Dictionary<uint, uint>
{
[10u] = 101u,
[11u] = 102u,
});
Assert.Equal(
new uint[] { 10u, 11u, 10u },
service.GetAppropriateFormula(50u));
Assert.True(service.IsComponentOwned(10u));
Assert.False(service.IsComponentOwned(11u));
}
private static SpellComponentRequirementService Service(
ClientObjectTable objects,
IReadOnlyList<uint> components,

View file

@ -245,6 +245,30 @@ public class DragDropSpineTests
return (root, list, list.Cell);
}
private static (UiRoot root, UiItemList list, UiCatalogSlot cell)
RootWithCatalogSlot(uint entryId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1))
{
Left = 0,
Top = 0,
Width = 32,
Height = 32,
};
list.Flush();
var cell = new UiCatalogSlot
{
EntryId = entryId,
Width = 32,
Height = 32,
SpriteResolve = _ => (1u, 1, 1),
};
list.AddItem(cell);
root.AddChild(list);
return (root, list, cell);
}
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
@ -359,6 +383,32 @@ public class DragDropSpineTests
Assert.Null(root.DragSource);
}
[Fact]
public void CatalogEntryPress_selectsBeforeRelease_withoutForgingItemIdentity()
{
var (root, list, cell) = RootWithCatalogSlot(42u);
var selected = new List<uint>();
list.PrimaryCatalogEntryPressed = selected.Add;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
Assert.Equal(new uint[] { 42u }, selected);
Assert.Equal(0u, cell.ItemId);
}
[Fact]
public void CatalogEntryRightClick_requestsLocalCatalogExamination()
{
var (root, list, _) = RootWithCatalogSlot(42u);
var examined = new List<uint>();
list.ExamineCatalogEntryRequested = examined.Add;
root.OnMouseDown(UiMouseButton.Right, 10, 10);
root.OnMouseUp(UiMouseButton.Right, 10, 10);
Assert.Equal(new uint[] { 42u }, examined);
}
[Fact]
public void CompletedDrag_doesNotFireUse()
{

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
@ -688,6 +689,103 @@ public sealed class AppraisalUiControllerTests
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),
};
int shown = 0;
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => shown++,
() => { },
selection: selection,
spellbook: spellbook,
resolveSpellIcon: id => id + 1_000u,
spellComponents: _ => components,
magicSkill: _ => 200u)!;
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);
}
private static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
@ -700,14 +798,20 @@ public sealed class AppraisalUiControllerTests
CreatureAppraisalRowTemplateFactory? creatureRows = null,
CreatureDisplayNameResolver? creatureNames = null,
SelectionState? selection = null,
RetailAppraisalNameResolver? itemNames = 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)
=> AppraisalUiController.Bind(
layout,
objects,
interaction,
selection ?? new SelectionState(),
combat,
new Spellbook(),
spellbook ?? new Spellbook(),
() => "Tester",
(objectId, text) => inscriptions.Add((objectId, text)),
messages.Add,
@ -715,7 +819,12 @@ public sealed class AppraisalUiControllerTests
close,
creatureRows,
creatureNames,
itemNames);
itemNames,
resolveSpellIcon,
resolveComponentIcon,
spellComponents,
magicSkill,
spellComponentTemplates);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
@ -749,4 +858,48 @@ public sealed class AppraisalUiControllerTests
ArmorEnchantments: null,
WeaponEnchantments: null,
ResistEnchantments: null);
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],
};
}

View file

@ -280,6 +280,45 @@ public sealed class SpellcastingUiControllerTests
Assert.Empty(used);
}
[Fact]
public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.OnSpellLearned(43u, 1f);
spellbook.SetFavorite(0, 0, 42u);
spellbook.SetFavorite(0, 1, 43u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var selection = new SelectionState();
var examined = new List<uint>();
using SpellcastingUiController controller = Bind(
layout,
spellbook,
objects,
_ => { },
selection: selection,
examineSpell: examined.Add)!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
Assert.True(first.Selected);
second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown));
Assert.False(first.Selected);
Assert.True(second.Selected);
Assert.Null(selection.SelectedObjectId);
second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick));
Assert.Equal(new uint[] { 43u }, examined);
Assert.Null(selection.SelectedObjectId);
}
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
@ -287,7 +326,9 @@ public sealed class SpellcastingUiControllerTests
Action<uint> useItem,
Action<int, int, uint>? addFavorite = null,
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u)
uint emptySlotSprite = 0u,
SelectionState? selection = null,
Action<uint>? examineSpell = null)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
@ -296,11 +337,12 @@ public sealed class SpellcastingUiControllerTests
spellId => spellId,
item => item.ObjectId,
useItem,
new SelectionState(),
selection ?? new SelectionState(),
addFavorite ?? ((_, _, _) => { }),
(_, _) => { },
shortcutDigits,
emptySlotSprite);
emptySlotSprite,
examineSpell);
}
private static void ApplyAnchors(UiElement parent)