Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button. Co-authored-by: OpenAI Codex <codex@openai.com>
268 lines
10 KiB
C#
268 lines
10 KiB
C#
using AcDream.App.Spells;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Spells;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public sealed class SpellcastingUiControllerTests
|
|
{
|
|
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
|
|
|
[Fact]
|
|
public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
ApplyAnchors(layout.Root);
|
|
var spellbook = new Spellbook();
|
|
spellbook.OnSpellLearned(42u, 1f);
|
|
spellbook.SetFavorite(0, 0, 42u);
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u];
|
|
uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u];
|
|
uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u];
|
|
var graphics = new UiShortcutSlotGraphics(regular, ghosted, empty, 0x060074CFu);
|
|
|
|
using SpellcastingUiController controller = Bind(
|
|
layout, spellbook, objects, _ => { }, shortcutGraphics: graphics)!;
|
|
|
|
UiElement group = layout.FindElement(0x100000AAu)!;
|
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
|
Assert.True(list.SingleRow);
|
|
Assert.True(list.FillVisibleEmptySlots);
|
|
Assert.Equal(439f, list.Width);
|
|
Assert.Equal(13, list.GetNumUIItems());
|
|
|
|
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
|
Assert.Equal(42u, favorite.EntryId);
|
|
Assert.Equal(0, favorite.ShortcutNum);
|
|
Assert.Same(regular, favorite.ActiveDigitArray());
|
|
|
|
for (int i = 1; i < 9; i++)
|
|
{
|
|
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(i));
|
|
Assert.True(slot.IsEmptySlot);
|
|
Assert.Equal(i, slot.ShortcutNum);
|
|
Assert.Same(empty, slot.ActiveDigitArray());
|
|
}
|
|
for (int i = 9; i < 13; i++)
|
|
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
|
|
|
|
var cast = Assert.IsType<UiButton>(
|
|
layout.FindElement(SpellcastingUiController.CastButtonId));
|
|
Assert.Equal((525f, 75f), (cast.Left, cast.Width));
|
|
Assert.Equal(3, cast.FaceSegmentCount);
|
|
Assert.Equal(
|
|
[
|
|
new UiPixelRect(0, 0, 31, 31),
|
|
new UiPixelRect(32, 0, 42, 31),
|
|
new UiPixelRect(43, 0, 74, 31),
|
|
],
|
|
cast.FaceSegmentRectsForTest());
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
var spellbook = new Spellbook();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
var used = new List<uint>();
|
|
using SpellcastingUiController? controller = Bind(
|
|
layout, spellbook, objects, used.Add);
|
|
|
|
Assert.True(controller is not null, DescribeBinding(layout));
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 2u,
|
|
Name = "Orb",
|
|
Type = ItemType.Caster,
|
|
WielderId = 1u,
|
|
CurrentlyEquippedLocation = EquipMask.Held,
|
|
SpellId = 2670u,
|
|
IconId = 0x06001234u,
|
|
});
|
|
controller.Tick();
|
|
|
|
UiElement host = Assert.IsAssignableFrom<UiElement>(
|
|
layout.FindElement(SpellcastingUiController.EndowmentId));
|
|
Assert.True(host.Visible);
|
|
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
|
|
Assert.Equal(2u, slot.EntryId);
|
|
Assert.Equal(2670u, slot.CatalogIconTexture);
|
|
Assert.Equal(2u, slot.CatalogOverlayTexture);
|
|
|
|
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
|
|
var cast = Assert.IsType<UiButton>(
|
|
layout.FindElement(SpellcastingUiController.CastButtonId));
|
|
cast.OnEvent(new UiEvent(0, cast, UiEventType.Click));
|
|
Assert.Equal([2u], used);
|
|
}
|
|
|
|
[Fact]
|
|
public void FavoriteDrop_IgnoresForeignInventoryPayload()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
var spellbook = new Spellbook();
|
|
spellbook.OnSpellLearned(42u, 1f);
|
|
spellbook.SetFavorite(0, 0, 42u);
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
using SpellcastingUiController? controller = Bind(
|
|
layout, spellbook, objects, _ => { });
|
|
|
|
Assert.True(controller is not null, DescribeBinding(layout));
|
|
controller.Tick();
|
|
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
|
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
|
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
|
var foreign = new ItemDragPayload(9u, ItemDragSource.Inventory, 0, new UiItemSlot(), null);
|
|
|
|
bool handled = slot.OnEvent(new UiEvent(
|
|
0, slot, UiEventType.DropReleased, Payload: foreign));
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal([42u], spellbook.GetFavorites(0));
|
|
}
|
|
|
|
[Fact]
|
|
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
var spellbook = new Spellbook();
|
|
spellbook.OnSpellLearned(42u, 1f);
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
var added = new List<(int Tab, int Position, uint Spell)>();
|
|
using SpellcastingUiController controller = Bind(
|
|
layout, spellbook, objects, _ => { },
|
|
(tab, position, spell) => added.Add((tab, position, spell)))!;
|
|
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
|
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
|
|
|
bool handled = list.OnEvent(new UiEvent(
|
|
0,
|
|
list,
|
|
UiEventType.DropReleased,
|
|
Data1: 5,
|
|
Payload: new SpellbookShortcutDragPayload(42u)));
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal([(0, 0, 42u)], added);
|
|
Assert.Equal([42u], spellbook.GetFavorites(0));
|
|
Assert.True(spellbook.Knows(42u));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
var spellbook = new Spellbook();
|
|
spellbook.OnSpellLearned(42u, 1f);
|
|
spellbook.SetFavorite(0, 0, 42u);
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
using SpellcastingUiController controller = Bind(
|
|
layout, spellbook, objects, _ => { })!;
|
|
UiElement group = layout.FindElement(0x100000AAu)!;
|
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
|
UiItemSlot before = list.GetItem(0)!;
|
|
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Rock" });
|
|
controller.Tick();
|
|
|
|
Assert.Same(before, list.GetItem(0));
|
|
}
|
|
|
|
[Fact]
|
|
public void ObjectTableClear_RemovesEquippedEndowmentAndCannotUseStaleGuid()
|
|
{
|
|
ImportedLayout layout = LayoutImporter.Build(
|
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
|
var spellbook = new Spellbook();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 2u,
|
|
Name = "Orb",
|
|
Type = ItemType.Caster,
|
|
WielderId = 1u,
|
|
CurrentlyEquippedLocation = EquipMask.Held,
|
|
SpellId = 2670u,
|
|
});
|
|
var used = new List<uint>();
|
|
using SpellcastingUiController controller = Bind(
|
|
layout, spellbook, objects, used.Add)!;
|
|
UiElement host = layout.FindElement(SpellcastingUiController.EndowmentId)!;
|
|
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
|
|
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
|
|
|
|
objects.Clear();
|
|
controller.Tick();
|
|
|
|
Assert.False(host.Visible);
|
|
Assert.Equal(0u, slot.EntryId);
|
|
slot.OnEvent(new UiEvent(0, slot, UiEventType.DoubleClick));
|
|
Assert.Empty(used);
|
|
}
|
|
|
|
private static SpellcastingUiController? Bind(
|
|
ImportedLayout layout,
|
|
Spellbook spellbook,
|
|
ClientObjectTable objects,
|
|
Action<uint> useItem,
|
|
Action<int, int, uint>? addFavorite = null,
|
|
UiShortcutSlotGraphics? shortcutGraphics = null)
|
|
{
|
|
var casting = new SpellCastingController(
|
|
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
|
return SpellcastingUiController.Bind(
|
|
layout, spellbook, casting, objects, () => 1u,
|
|
spellId => spellId,
|
|
item => item.ObjectId,
|
|
useItem,
|
|
new SelectionState(),
|
|
addFavorite ?? ((_, _, _) => { }),
|
|
(_, _) => { },
|
|
shortcutGraphics);
|
|
}
|
|
|
|
private static void ApplyAnchors(UiElement parent)
|
|
{
|
|
foreach (UiElement child in parent.Children)
|
|
{
|
|
child.ApplyAnchor(parent.Width, parent.Height);
|
|
ApplyAnchors(child);
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<UiElement> Descendants(UiElement root)
|
|
{
|
|
foreach (UiElement child in root.Children)
|
|
{
|
|
yield return child;
|
|
foreach (UiElement nested in Descendants(child)) yield return nested;
|
|
}
|
|
}
|
|
|
|
private static string DescribeBinding(ImportedLayout layout)
|
|
{
|
|
uint[] ids =
|
|
[
|
|
SpellcastingUiController.CastButtonId, SpellcastingUiController.EndowmentId,
|
|
0x100000A3u, 0x100000AAu, 0x100005C2u, 0x100005C3u,
|
|
];
|
|
return string.Join(", ", ids.Select(id =>
|
|
$"{id:X8}={layout.FindElement(id)?.GetType().Name ?? "missing"}"));
|
|
}
|
|
}
|