feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -11,12 +11,12 @@ public sealed class CombatUiControllerTests
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void CombatMode_ShowsOnlyTargetedModes_AndSelectsMediumByDefault()
public void CombatMode_ShowsPhysicalAndMagicPages_AndSelectsMediumByDefault()
{
double now = 0d;
var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => now, []);
var (layout, _, _, power, high, medium, low) = BuildLayout();
var (layout, basic, spellcasting, power, high, medium, low) = BuildLayout();
var visibility = new List<bool>();
GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind(
@ -27,7 +27,9 @@ public sealed class CombatUiControllerTests
combat.SetCombatMode(CombatMode.Melee);
combat.SetCombatMode(CombatMode.Magic);
Assert.Equal([false, true, false], visibility);
Assert.Equal([false, true, true], visibility);
Assert.False(basic.Visible);
Assert.True(spellcasting.Visible);
Assert.False(high.Selected);
Assert.True(medium.Selected);
Assert.False(low.Selected);

View file

@ -0,0 +1,102 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsIndicatorControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void AuthoredFixture_BuildsRetailIndicatorButtons()
{
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
Assert.Equal(0x10000610u, info.Id);
Assert.Equal(150f, info.Width);
Assert.Equal(30f, info.Height);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
}
[Fact]
public void Enchantments_GhostAndEnableMatchingIndicator_ThenClickCorrectPanel()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n"));
var spellbook = new Spellbook(table);
ImportedLayout layout = BuildSyntheticLayout();
var toggled = new List<uint>();
using EffectsIndicatorController controller = EffectsIndicatorController.Bind(
layout, spellbook, toggled.Add)!;
UiButton helpful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
UiButton harmful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 10u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 11u, 60f, 2u, Bucket: 8u, SpellCategory: 43u));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u, PowerLevel: 1u));
Assert.True(helpful.Enabled);
Assert.False(harmful.Enabled);
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.PositiveEffects], toggled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 1u, 60f, 2u, Bucket: 2u, SpellCategory: 99u, PowerLevel: 7u));
// The harmful spell wins the shared category in the effects list, but
// retail's raw registry counters keep both indicator types enabled.
Assert.True(helpful.Enabled);
Assert.True(harmful.Enabled);
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
Assert.Equal(
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
toggled);
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.False(helpful.Enabled);
Assert.True(harmful.Enabled);
}
private static ImportedLayout BuildSyntheticLayout()
{
var root = new ElementInfo { Id = 1u, Type = 3u, Width = 150f, Height = 30f };
var helpful = ButtonInfo(EffectsIndicatorController.HelpfulButtonId);
var harmful = ButtonInfo(EffectsIndicatorController.HarmfulButtonId);
return LayoutImporter.BuildFromInfos(root, [helpful, harmful], NoTex, null);
}
private static ElementInfo ButtonInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = EffectsIndicatorController.RetailClassId,
Width = 20f,
Height = 20f,
DefaultStateId = UiButtonStateMachine.Normal,
};
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
{
Id = UiButtonStateMachine.Normal,
Name = "Normal",
};
info.States[UiButtonStateMachine.Ghosted] = new UiStateInfo
{
Id = UiButtonStateMachine.Ghosted,
Name = "Ghosted",
};
return info;
}
}

View file

@ -0,0 +1,198 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void Rebuild_DoesNotAutoSelectFirstEffect_AndClearsRemovedSelection()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n"));
var spellbook = new Spellbook(table);
var root = new UiPanel { Width = 160f, Height = 100f };
var list = new UiItemList(NoTex) { Width = 160f, Height = 64f };
var info = new UiText { Width = 160f, Height = 32f };
root.AddChild(list);
root.AddChild(info);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[EffectsUiController.ListId] = list,
[EffectsUiController.InfoTextId] = info,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 2u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 3u, 60f, 1u, Bucket: 8u, SpellCategory: 42u));
Assert.Null(controller.SelectedSpellId);
Assert.Equal(1, list.GetNumUIItems());
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.Null(controller.SelectedSpellId);
}
[Fact]
public void DuplicateLayersOfSameSpell_ShareRetailSelection()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n"));
var spellbook = new Spellbook(table);
var root = new UiPanel { Width = 160f, Height = 100f };
var list = new UiItemList(NoTex) { Width = 160f, Height = 64f };
root.AddChild(list);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[EffectsUiController.ListId] = list,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 100u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 101u));
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
first.OnEvent(new UiEvent(0, first, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
Assert.True(first.Selected);
Assert.True(second.Selected);
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
Assert.Null(controller.SelectedSpellId);
Assert.False(first.Selected);
Assert.False(second.Selected);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AuthoredEffectsFixture_InheritsListAndInfo_AndBinds(bool positive)
{
ElementInfo info = positive
? FixtureLoader.LoadPositiveEffectsInfos()
: FixtureLoader.LoadNegativeEffectsInfos();
uint expectedRoot = positive
? EffectsUiController.PositiveRootId
: EffectsUiController.NegativeRootId;
Assert.Equal(expectedRoot, info.Id);
Assert.Equal(0x1000001Bu, info.Type);
Assert.Equal(300f, info.Width);
Assert.Equal(362f, info.Height);
Assert.True(info.TryGetEffectiveProperty(0x1000000Cu, out UiPropertyValue effectType));
Assert.Equal(UiPropertyKind.Enum, effectType.Kind);
Assert.Equal(positive ? 1u : 2u, effectType.UnsignedValue);
Assert.Equal(
positive,
info.TryGetEffectiveBool(0x10000049u, out bool restorePrevious)
&& restorePrevious);
ElementInfo[] children = info.Children.OrderBy(child => child.ReadOrder).ToArray();
Assert.Equal(
[0x100000FCu, 0x10000120u, 0x10000123u, 0x10000124u, 0x10000125u],
children.Select(child => child.Id));
Assert.Equal([1u, 2u, 3u, 4u, 5u], children.Select(child => child.ReadOrder));
Assert.Equal(children.Length, children.Select(child => child.Id).Distinct().Count());
ElementInfo listInfo = Assert.Single(children, child => child.Id == EffectsUiController.ListId);
Assert.Equal(5u, listInfo.Type);
Assert.Equal(300f, listInfo.Width);
Assert.Equal(249f, listInfo.Height);
Assert.Equal(12u, FindInfo(info, EffectsUiController.InfoTextId)?.Type);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
var spellbook = new Spellbook();
int closes = 0;
using EffectsUiController? controller = EffectsUiController.Bind(
layout, spellbook, positive, () => 0d, NoTex, id => id, () => closes++);
Assert.NotNull(controller);
Assert.NotNull(layout.FindElement(EffectsUiController.ListId));
Assert.IsType<UiText>(layout.FindElement(EffectsUiController.InfoTextId));
Assert.Equal(expectedRoot, layout.Root.DatElementId);
UiButton close = Assert.IsType<UiButton>(layout.FindElement(EffectsUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
uint panelId = positive
? RetailPanelCatalog.PositiveEffects
: RetailPanelCatalog.NegativeEffects;
Assert.True(RetailPanelCatalog.TryGetWindowName(panelId, out string windowName));
Assert.Equal(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
windowName);
Assert.DoesNotContain(
RetailPanelCatalog.ToolbarPanels,
entry => entry.PanelId == panelId);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AuthoredRestorePreviousProperty_DrivesRetailPanelLifecycle(bool positive)
{
ElementInfo info = positive
? FixtureLoader.LoadPositiveEffectsInfos()
: FixtureLoader.LoadNegativeEffectsInfos();
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
[WindowNames.Inventory] = false,
[positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects] = false,
};
var panelUi = new RetailPanelUiController(
name => visible[name],
name =>
{
visible[name] = true;
return true;
},
name =>
{
visible[name] = false;
return true;
});
uint effectsPanel = positive
? RetailPanelCatalog.PositiveEffects
: RetailPanelCatalog.NegativeEffects;
string effectsWindow = positive
? WindowNames.PositiveEffects
: WindowNames.NegativeEffects;
panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
panelUi.Register(
effectsPanel,
effectsWindow,
info);
panelUi.SetPanelVisibility(RetailPanelCatalog.Inventory, visible: true);
panelUi.SetPanelVisibility(effectsPanel, visible: true);
panelUi.SetPanelVisibility(effectsPanel, visible: false);
Assert.Equal(positive, visible[WindowNames.Inventory]);
Assert.False(visible[effectsWindow]);
Assert.Equal(
positive ? RetailPanelCatalog.Inventory : null,
panelUi.ActivePanelId);
}
private static ElementInfo? FindInfo(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (FindInfo(child, id) is { } found)
return found;
return null;
}
}

View file

@ -113,6 +113,24 @@ public static class FixtureLoader
public static ElementInfo LoadFpsDisplayInfos()
=> LoadInfos("smartbox_fps_2100000F.json");
public static ImportedLayout LoadPositiveEffects()
=> LayoutImporter.Build(LoadPositiveEffectsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPositiveEffectsInfos()
=> LoadInfos("effects_positive_2100001B.json");
public static ImportedLayout LoadNegativeEffects()
=> LayoutImporter.Build(LoadNegativeEffectsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadNegativeEffectsInfos()
=> LoadInfos("effects_negative_2100001B.json");
public static ImportedLayout LoadIndicators()
=> LayoutImporter.Build(LoadIndicatorsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadIndicatorsInfos()
=> LoadInfos("indicators_21000071.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -22,6 +22,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
(0x21000072u, "powerbar_21000072.json"),
(0x21000073u, "combat_21000073.json"),
(0x21000074u, "radar_21000074.json"),
@ -73,7 +74,7 @@ public sealed class RetailLayoutFixtureGenerator
Assert.Equal(0x4000001Au, fps!.FontDid);
var fpsStrings = new DatStringResolver(dats);
Assert.Equal("FPS: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 0));
Assert.Equal("\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1));
Assert.Equal(@"\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1));
var fpsJson = JsonSerializer.Serialize(fps, new JsonSerializerOptions
{
IncludeFields = true,
@ -83,6 +84,23 @@ public sealed class RetailLayoutFixtureGenerator
Path.Combine(FixtureDirectory(), $"smartbox_fps_{smartboxLayoutDid:X8}.json"),
fpsJson);
foreach ((uint rootId, string fileName) in new[]
{
(EffectsUiController.PositiveRootId, "effects_positive_2100001B.json"),
(EffectsUiController.NegativeRootId, "effects_negative_2100001B.json"),
})
{
ElementInfo? effects = LayoutImporter.ImportInfos(
dats, EffectsUiController.LayoutId, rootId);
Assert.NotNull(effects);
var effectsJson = JsonSerializer.Serialize(effects, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
}
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);

View file

@ -0,0 +1,90 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailPanelUiControllerTests
{
[Fact]
public void ShowingPanel_HidesCurrent_AndClosingLeavesHostEmpty()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["inventory"] = false,
["helpful"] = false,
};
var controller = Create(visible);
controller.Register(7u, "inventory");
controller.Register(4u, "helpful");
Assert.True(controller.SetPanelVisibility(7u, true));
Assert.True(visible["inventory"]);
Assert.Equal(7u, controller.ActivePanelId);
Assert.True(controller.SetPanelVisibility(4u, true));
Assert.False(visible["inventory"]);
Assert.True(visible["helpful"]);
Assert.Equal(4u, controller.ActivePanelId);
Assert.False(controller.TogglePanel(4u));
Assert.False(visible["helpful"]);
Assert.Null(controller.ActivePanelId);
}
[Fact]
public void RestorePreviousProperty_ReopensOnlyNonRestoringPreviousPanel()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["ordinary"] = false,
["transient"] = false,
};
var controller = Create(visible);
controller.Register(1u, "ordinary", restorePrevious: false);
controller.Register(2u, "transient", restorePrevious: true);
controller.SetPanelVisibility(1u, true);
controller.SetPanelVisibility(2u, true);
Assert.False(visible["ordinary"]);
Assert.True(visible["transient"]);
controller.SetPanelVisibility(2u, false);
Assert.True(visible["ordinary"]);
Assert.False(visible["transient"]);
Assert.Equal(1u, controller.ActivePanelId);
}
[Fact]
public void ObservedPersistenceShow_UsesSameExclusiveLifecycle()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["first"] = false,
["second"] = false,
};
var controller = Create(visible);
controller.Register(1u, "first");
controller.Register(2u, "second");
controller.SetPanelVisibility(1u, true);
visible["second"] = true;
controller.ObserveWindowVisibility("second", visible: true);
Assert.False(visible["first"]);
Assert.True(visible["second"]);
Assert.Equal(2u, controller.ActivePanelId);
}
private static RetailPanelUiController Create(Dictionary<string, bool> visible)
=> new(
name => visible[name],
name =>
{
visible[name] = true;
return true;
},
name =>
{
visible[name] = false;
return true;
});
}

View file

@ -0,0 +1,173 @@
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_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 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)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
useItem,
new SelectionState(),
(_, _, _) => { },
(_, _) => { });
}
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"}"));
}
}

View file

@ -1097,7 +1097,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741825,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -3320,7 +3320,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741824,
"HJustify": 1,
"HJustify": 0,
"VJustify": 2,
"FontColor": {
"X": 0.8,
@ -6248,7 +6248,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741824,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -2035,7 +2035,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741825,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -19338,7 +19338,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -20278,7 +20278,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -20859,7 +20859,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -21469,7 +21469,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -287,6 +287,382 @@
"StateCursors": {},
"DefaultStateName": "",
"Children": [
{
"Id": 268435977,
"Type": 3,
"X": 0,
"Y": 13,
"Width": 4,
"Height": 53,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 2,
"Bottom": 1,
"ReadOrder": 6,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687166,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687166,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435978,
"Type": 3,
"X": 74,
"Y": 13,
"Width": 4,
"Height": 53,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 7,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687166,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687166,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435979,
"Type": 3,
"X": 13,
"Y": 73,
"Width": 52,
"Height": 6,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 2,
"Right": 1,
"Bottom": 1,
"ReadOrder": 8,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687165,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687165,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436145,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 13,
"Height": 13,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 2,
"Bottom": 2,
"ReadOrder": 1,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687161,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687161,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436146,
"Type": 3,
"X": 65,
"Y": 0,
"Width": 13,
"Height": 13,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 1,
"Right": 1,
"Bottom": 2,
"ReadOrder": 2,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687162,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687162,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436147,
"Type": 3,
"X": 0,
"Y": 65,
"Width": 13,
"Height": 14,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 2,
"Right": 2,
"Bottom": 1,
"ReadOrder": 3,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687163,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687163,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436148,
"Type": 3,
"X": 65,
"Y": 65,
"Width": 13,
"Height": 14,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 2,
"Right": 1,
"Bottom": 1,
"ReadOrder": 4,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687164,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687164,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436149,
"Type": 3,
"X": 13,
"Y": 0,
"Width": 52,
"Height": 6,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 2,
"ReadOrder": 5,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687165,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687165,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436271,
"Type": 3,
@ -301,7 +677,7 @@
"Top": 2,
"Right": 3,
"Bottom": 1,
"ReadOrder": 2,
"ReadOrder": 10,
"ZLevel": 0,
"States": {
"4294967295": {
@ -2069,7 +2445,7 @@
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 1,
"ReadOrder": 9,
"ZLevel": 0,
"States": {
"4294967295": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8940,7 +8940,7 @@
},
"DefaultStateId": 1,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -8151,7 +8151,7 @@
},
"DefaultStateId": 1,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,