feat(ui): port retail spellbook interactions
Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar. Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
ad30c37a48
commit
ac2ca8f965
17 changed files with 727 additions and 47 deletions
32
tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs
Normal file
32
tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class SpellbookRowStyleTests
|
||||
{
|
||||
[Fact]
|
||||
public void InstalledDat_ResolvesPinnedRetailSpellShortcutPrototype()
|
||||
{
|
||||
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir)) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
SpellbookRowStyle style = Assert.IsType<SpellbookRowStyle>(
|
||||
SpellbookRowStyle.TryLoad(dats));
|
||||
|
||||
Assert.Equal(280f, style.Width);
|
||||
Assert.Equal(32f, style.Height);
|
||||
Assert.Equal(0f, style.IconLeft);
|
||||
Assert.Equal(32f, style.IconWidth);
|
||||
Assert.Equal(42f, style.LabelLeft);
|
||||
Assert.Equal(230f, style.LabelWidth);
|
||||
Assert.Equal(0x40000001u, style.FontDid);
|
||||
Assert.Equal(System.Numerics.Vector4.One, style.LabelColor);
|
||||
Assert.Equal(0x06001396u, style.BackgroundSprite);
|
||||
Assert.Equal(0x06001397u, style.SelectedSprite);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,30 +3,33 @@ using AcDream.App.UI;
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class SpellbookWindowControllerTests
|
||||
{
|
||||
private static readonly SpellbookRowStyle RowStyle = new(
|
||||
Width: 280f,
|
||||
Height: 32f,
|
||||
IconLeft: 0f,
|
||||
IconTop: 0f,
|
||||
IconWidth: 32f,
|
||||
IconHeight: 32f,
|
||||
LabelLeft: 42f,
|
||||
LabelWidth: 230f,
|
||||
FontDid: 0x40000001u,
|
||||
LabelColor: Vector4.One,
|
||||
BackgroundSprite: 0x06001396u,
|
||||
SelectedSprite: 0x06001397u);
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
var closed = new List<uint>();
|
||||
using SpellbookWindowController? controller = SpellbookWindowController.Bind(
|
||||
layout,
|
||||
new Spellbook(),
|
||||
new ClientObjectTable(),
|
||||
() => 1u,
|
||||
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
spellId => spellId,
|
||||
iconId => iconId,
|
||||
_ => 1,
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
(_, _) => { },
|
||||
() => closed.Add(1u));
|
||||
using SpellbookWindowController? controller = Bind(
|
||||
layout, new Spellbook(), close: () => closed.Add(1u));
|
||||
|
||||
Assert.NotNull(controller);
|
||||
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
|
||||
|
|
@ -53,4 +56,176 @@ public sealed class SpellbookWindowControllerTests
|
|||
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||
Assert.Empty(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u);
|
||||
book.OnSpellLearned(102u);
|
||||
book.OnSpellLearned(103u);
|
||||
var added = new List<uint>();
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout, book, addFavorite: added.Add)!;
|
||||
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SpellbookWindowController.SpellScrollbarId));
|
||||
|
||||
Assert.Equal(1, list.Columns);
|
||||
Assert.Equal(280f, list.CellWidth);
|
||||
Assert.Equal(32f, list.CellHeight);
|
||||
Assert.Same(list.Scroll, scrollbar.Model);
|
||||
Assert.Equal(3, list.GetNumUIItems());
|
||||
|
||||
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
|
||||
UiCatalogSlot third = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
|
||||
Assert.Equal([102u, 101u, 103u], [first.EntryId, second.EntryId, third.EntryId]);
|
||||
Assert.Equal("Creature One", first.Label);
|
||||
Assert.True(first.ShowLabel);
|
||||
Assert.Equal(RowStyle.BackgroundSprite, first.BackgroundSprite);
|
||||
Assert.Equal(RowStyle.SelectedSprite, first.SelectedSprite);
|
||||
Assert.True(first.SelectionBehindContent);
|
||||
Assert.Equal(RowStyle.LabelLeft, first.LabelLeft);
|
||||
|
||||
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
|
||||
Assert.True(second.Selected);
|
||||
Assert.False(first.Selected);
|
||||
|
||||
first.OnEvent(new UiEvent(
|
||||
0, first, UiEventType.DragBegin,
|
||||
Payload: new SpellbookShortcutDragPayload(first.EntryId)));
|
||||
Assert.True(first.Selected);
|
||||
Assert.IsType<SpellbookShortcutDragPayload>(first.GetDragPayload());
|
||||
|
||||
first.OnEvent(new UiEvent(0, first, UiEventType.DoubleClick));
|
||||
Assert.Equal([102u], added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchoolAndLevelFilters_RebuildFromAuthoritativeBitfield()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u); // War I
|
||||
book.OnSpellLearned(102u); // Creature I
|
||||
book.OnSpellLearned(103u); // War VIII
|
||||
var sent = new List<uint>();
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout, book, sendFilter: sent.Add)!;
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiButton war = Assert.IsType<UiButton>(layout.FindElement(0x1000029Bu));
|
||||
UiButton levelOne = Assert.IsType<UiButton>(layout.FindElement(0x1000029Cu));
|
||||
|
||||
Assert.True(war.Selected);
|
||||
Assert.Equal(3, list.GetNumUIItems());
|
||||
|
||||
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
|
||||
controller.Tick();
|
||||
Assert.Equal([0x3FF7u], sent);
|
||||
Assert.Single(VisibleSpellIds(list));
|
||||
Assert.Equal(102u, VisibleSpellIds(list)[0]);
|
||||
|
||||
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
|
||||
levelOne.OnEvent(new UiEvent(0, levelOne, UiEventType.Click));
|
||||
controller.Tick();
|
||||
Assert.Equal([103u], VisibleSpellIds(list));
|
||||
Assert.False(levelOne.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_UsesSharedConfirmation_AndWaitsForServerRemovalNotice()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u);
|
||||
var removed = new List<uint>();
|
||||
string? prompt = null;
|
||||
Action<bool>? complete = null;
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout,
|
||||
book,
|
||||
removeSpell: removed.Add,
|
||||
showConfirmation: (message, callback) =>
|
||||
{
|
||||
prompt = message;
|
||||
complete = callback;
|
||||
})!;
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiButton delete = Assert.IsType<UiButton>(
|
||||
layout.FindElement(SpellbookWindowController.DeleteButtonId));
|
||||
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
Assert.Null(prompt);
|
||||
|
||||
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
|
||||
Assert.Equal(
|
||||
"Are you sure you want to remove War One from your spellbook? "
|
||||
+ "You will no longer be able to cast this spell unless you learn it again!",
|
||||
prompt);
|
||||
Assert.Empty(removed);
|
||||
complete!(false);
|
||||
Assert.Empty(removed);
|
||||
Assert.True(book.Knows(101u));
|
||||
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
complete!(true);
|
||||
Assert.Equal([101u], removed);
|
||||
Assert.True(book.Knows(101u));
|
||||
Assert.Single(VisibleSpellIds(list));
|
||||
|
||||
book.OnSpellForgotten(101u);
|
||||
controller.Tick();
|
||||
Assert.False(book.Knows(101u));
|
||||
Assert.Empty(VisibleSpellIds(list));
|
||||
}
|
||||
|
||||
private static SpellbookWindowController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
Action<uint>? addFavorite = null,
|
||||
Action<uint>? sendFilter = null,
|
||||
Action<uint>? removeSpell = null,
|
||||
Action<string, Action<bool>>? showConfirmation = null,
|
||||
Action? close = null)
|
||||
=> SpellbookWindowController.Bind(
|
||||
layout,
|
||||
spellbook,
|
||||
new ClientObjectTable(),
|
||||
() => 1u,
|
||||
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
spellId => spellId + 1000u,
|
||||
iconId => iconId,
|
||||
spellId => spellId == 103u ? 8 : 1,
|
||||
_ => { },
|
||||
addFavorite ?? (_ => { }),
|
||||
sendFilter ?? (_ => { }),
|
||||
removeSpell ?? (_ => { }),
|
||||
showConfirmation ?? ((_, _) => { }),
|
||||
(_, _) => { },
|
||||
close ?? (() => { }),
|
||||
RowStyle,
|
||||
rowFont: null);
|
||||
|
||||
private static Spellbook CreateSpellbook()
|
||||
{
|
||||
const string csv = "Spell ID,Name,School,IconId [Hex],SortKey\n"
|
||||
+ "101,War One,War Magic,0x06000001,2\n"
|
||||
+ "102,Creature One,Creature Enchantment,0x06000002,1\n"
|
||||
+ "103,War Eight,War Magic,0x06000003,3\n";
|
||||
return new Spellbook(SpellTable.LoadFromReader(new StringReader(csv)));
|
||||
}
|
||||
|
||||
private static uint[] VisibleSpellIds(UiItemList list)
|
||||
=> Enumerable.Range(0, list.GetNumUIItems())
|
||||
.Select(index => Assert.IsType<UiCatalogSlot>(list.GetItem(index)).EntryId)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,35 @@ public sealed class SpellcastingUiControllerTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -137,7 +166,8 @@ public sealed class SpellcastingUiControllerTests
|
|||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Action<uint> useItem)
|
||||
Action<uint> useItem,
|
||||
Action<int, int, uint>? addFavorite = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
|
|
@ -147,7 +177,7 @@ public sealed class SpellcastingUiControllerTests
|
|||
item => item.ObjectId,
|
||||
useItem,
|
||||
new SelectionState(),
|
||||
(_, _, _) => { },
|
||||
addFavorite ?? ((_, _, _) => { }),
|
||||
(_, _) => { });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,4 +43,43 @@ public class UiItemListTests
|
|||
list.CellEmptySprite = 0u;
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogDrop_routes_payload_and_local_coordinates()
|
||||
{
|
||||
var list = new UiItemList();
|
||||
object payload = new();
|
||||
(object Payload, int X, int Y)? received = null;
|
||||
list.CatalogDropped = (value, x, y) => received = (value, x, y);
|
||||
|
||||
bool handled = list.OnEvent(new UiEvent(
|
||||
0, list, UiEventType.DropReleased, Data1: 17, Data2: 9, Payload: payload));
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.NotNull(received);
|
||||
Assert.Same(payload, received.Value.Payload);
|
||||
Assert.Equal((17, 9), (received.Value.X, received.Value.Y));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport()
|
||||
{
|
||||
var list = new UiItemList
|
||||
{
|
||||
Width = 100f,
|
||||
Height = 64f,
|
||||
Columns = 1,
|
||||
CellWidth = 100f,
|
||||
CellHeight = 32f,
|
||||
};
|
||||
list.Flush();
|
||||
for (int i = 0; i < 5; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
list.ScrollItemIntoView(3);
|
||||
Assert.Equal(64, list.Scroll.ScrollY);
|
||||
list.ScrollItemIntoView(2);
|
||||
Assert.Equal(64, list.Scroll.ScrollY);
|
||||
list.ScrollItemIntoView(0);
|
||||
Assert.Equal(0, list.Scroll.ScrollY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue