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

@ -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;
}
}