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,115 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class RetailSpellTargetPolicyTests
{
[Fact]
public void RejectsStackedTarget()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 2u, Name = "Scarabs", Type = ItemType.SpellComponents, StackSize = 2 },
Spell(targetMask: (uint)ItemType.SpellComponents));
Assert.False(result.Allowed);
Assert.Contains("stack", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RejectsSelfWithoutRetailSpecialTargetBits()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 1u, Name = "Self", Type = ItemType.Creature },
Spell(targetMask: (uint)ItemType.Creature));
Assert.False(result.Allowed);
Assert.Contains("yourself", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AcceptsSelfWithSpecialTargetBits()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 1u,
Name = "Self",
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Player,
},
Spell(targetMask: 0x8101u));
Assert.True(result.Allowed);
}
[Theory]
[InlineData(PublicWeenieFlags.None, 0u, false)]
[InlineData(PublicWeenieFlags.Attackable, 0u, true)]
[InlineData(PublicWeenieFlags.Player, 0u, true)]
[InlineData(PublicWeenieFlags.Attackable, 99u, false)]
public void SpecialTargetBits_RequirePlayerOrAttackableNonPet(
PublicWeenieFlags flags,
uint petOwner,
bool expected)
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 2u,
Name = "Target",
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)flags,
PetOwnerId = petOwner,
},
Spell(targetMask: 0x8101u));
Assert.Equal(expected, result.Allowed);
}
[Fact]
public void RejectsTypeMaskMismatchWithObjectName()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 2u, Name = "Sword", Type = ItemType.MeleeWeapon },
Spell(targetMask: (uint)ItemType.Creature));
Assert.False(result.Allowed);
Assert.Contains("Sword", result.Message);
}
[Theory]
[InlineData(PublicWeenieFlags.Attackable, 0u, true)]
[InlineData(PublicWeenieFlags.None, 0u, false)]
[InlineData(PublicWeenieFlags.Player, 0u, true)]
[InlineData(PublicWeenieFlags.Attackable, 9u, false)]
public void DirectTypeMatch_StillRequiresPlayerOrAttackableNonPet(
PublicWeenieFlags flags,
uint petOwner,
bool expected)
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 2u,
Name = "Target",
Type = ItemType.Misc,
PublicWeenieBitfield = (uint)flags,
PetOwnerId = petOwner,
},
Spell(targetMask: (uint)ItemType.Misc));
Assert.Equal(expected, result.Allowed);
}
private static SpellMetadata Spell(uint targetMask) => new(
1, "Test", "War Magic", 1, 0, "", 0, 0, false, false, "",
0, 0, 0, 1, false, false, false, 0, 0, 0, targetMask, 0);
}

View file

@ -0,0 +1,119 @@
using AcDream.App.Spells;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellCastingControllerTests
{
[Fact]
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
{
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
uint target = 0;
var controller = MakeController(book, selected: 99, player: 42,
sendTargeted: (id, _) => target = id);
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(42u, target);
}
[Fact]
public void Cast_TargetedWithoutSelection_DoesNotSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
int sends = 0;
string message = "";
var controller = MakeController(book, selected: null, player: 42,
sendTargeted: (_, _) => sends++, display: value => message = value);
Assert.Equal(CastRequestResult.NoTarget, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Contains("select", message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var order = new List<string>();
var controller = MakeController(book, selected: null, player: 42,
stop: () => order.Add("stop"),
sendUntargeted: _ => order.Add("send"),
incrementBusy: () => order.Add("busy"));
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(["stop", "send", "busy"], order);
}
[Fact]
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int sends = 0, busy = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => sends++, hasComponents: _ => false,
incrementBusy: () => busy++);
Assert.Equal(CastRequestResult.MissingComponents, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Equal(0, busy);
}
[Fact]
public void Cast_Disconnected_DoesNotIncrementBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int busy = 0;
var controller = MakeController(book, selected: null, player: 42,
incrementBusy: () => busy++, canSend: () => false);
Assert.Equal(CastRequestResult.Unavailable, controller.Cast(1));
Assert.Equal(0, busy);
}
[Fact]
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int increments = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => throw new InvalidOperationException("wire"),
incrementBusy: () => increments++);
Assert.Throws<InvalidOperationException>(() => controller.Cast(1));
Assert.Equal(0, increments);
Assert.Null(controller.LastRequestedSpellId);
}
private static SpellCastingController MakeController(
Spellbook book,
uint? selected,
uint player,
Action? stop = null,
Action<uint>? sendUntargeted = null,
Action<uint, uint>? sendTargeted = null,
Action<string>? display = null,
Func<uint, bool>? hasComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null) => new(
book,
() => selected,
() => player,
stop ?? (() => { }),
sendUntargeted ?? (_ => { }),
sendTargeted ?? ((_, _) => { }),
display ?? (_ => { }),
hasRequiredComponents: hasComponents,
incrementBusy: incrementBusy,
canSend: canSend);
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
{
const string header = "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
SpellTable table = SpellTable.LoadFromReader(new StringReader($"{header}\n{row}"));
var book = new Spellbook(table);
book.OnSpellLearned(1);
return book;
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellComponentRequirementServiceTests
{
[Fact]
public void RequiresEveryFormulaScidMappedToOwnedWcid()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 100u,
ContainerId = 1u,
IsComponentPack = true,
});
var service = Service(objects, [10u, 11u], new Dictionary<uint, uint>
{
[10u] = 100u,
[11u] = 101u,
});
Assert.False(service.HasRequiredComponents(50u));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
IsComponentPack = true,
});
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void ServerDisabledComponents_BypassesInventoryPreflight()
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
player.Properties.Bools[SpellComponentRequirementService.SpellComponentsRequiredProperty] = false;
objects.AddOrUpdate(player);
Assert.True(Service(objects, [10u], new Dictionary<uint, uint>())
.HasRequiredComponents(50u));
}
[Fact]
public void PersonalizedFormula_UsesExactCanonicalAccountSpelling()
{
const string canonical = "CanonicalAccount";
const string loginSpelling = "canonicalaccount";
uint[] formula = [6u, 63u, 10u, 64u, 20u, 30u, 65u, 40u];
IReadOnlyList<uint> canonicalFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, canonical);
IReadOnlyList<uint> loginFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, loginSpelling);
Assert.NotEqual(canonicalFormula, loginFormula);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var map = canonicalFormula.Where(scid => scid != 0u)
.Distinct()
.ToDictionary(scid => scid, scid => scid + 1000u);
foreach (uint wcid in map.Values)
objects.AddOrUpdate(new ClientObject
{
ObjectId = wcid + 10000u,
WeenieClassId = wcid,
ContainerId = 1u,
});
string account = canonical;
var service = new SpellComponentRequirementService(
objects, () => 1u, () => account,
new Dictionary<uint, SpellFormulaDefinition> { [50u] = new(3u, formula) },
map);
Assert.True(service.HasRequiredComponents(50u));
account = loginSpelling;
Assert.False(service.HasRequiredComponents(50u));
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void InfusedSchoolOrDirectlyCarriedFocus_UsesScarabOnlyFormula(
bool infused,
bool carriesFocus)
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
if (infused)
player.Properties.Ints[0x129u] = 1;
objects.AddOrUpdate(player);
if (carriesFocus)
{
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 900u,
ContainerId = 1u,
Type = ItemType.Container,
});
}
// War I scarab-only formula is SCID 1 + one prismatic taper (0xBC).
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 4u,
WeenieClassId = 188u,
ContainerId = 1u,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u, 10u], School: 1u),
},
new Dictionary<uint, uint>
{
[1u] = 101u,
[0xBCu] = 188u,
},
new Dictionary<uint, uint> { [1u] = 900u });
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void FocusNestedInsidePack_DoesNotCountAsRetailMagicPackOwnership()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 700u,
ContainerId = 1u,
Type = ItemType.Container,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 900u,
ContainerId = 2u,
Type = ItemType.Container,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u], School: 1u),
},
new Dictionary<uint, uint>(),
new Dictionary<uint, uint> { [1u] = 900u });
Assert.False(service.HasRequiredComponents(50u));
}
private static SpellComponentRequirementService Service(
ClientObjectTable objects,
IReadOnlyList<uint> components,
IReadOnlyDictionary<uint, uint> map) => new(
objects,
() => 1u,
() => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, components),
},
map);
}

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,

View file

@ -55,6 +55,17 @@ public class UiItemSlotTests
Assert.True(fired);
}
[Fact]
public void CatalogSlot_keeps_catalog_identity_separate_from_object_guid()
{
var slot = new UiCatalogSlot { EntryId = 1234u, CatalogIconTexture = 99u };
Assert.Equal(1234u, slot.EntryId);
Assert.Equal(0u, slot.ItemId);
Assert.False(slot.IsDragSource);
Assert.Null(slot.GetDragPayload());
}
// ── Shortcut number tests ────────────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).

View file

@ -460,8 +460,8 @@ public sealed class GameEventWiringTests
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
{
var (d, _, _, book, _) = MakeAll();
book.OnEnchantmentAdded(1, 1, 100f, 0);
book.OnEnchantmentAdded(2, 2, 100f, 0);
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
Assert.Equal(2, book.ActiveCount);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
@ -470,6 +470,32 @@ public sealed class GameEventWiringTests
Assert.Equal(0, book.ActiveCount);
}
[Fact]
public void WireAll_MagicUpdateEnchantment_ConvertsRelativeTimesAndClassifiesBucket()
{
var dispatcher = new GameEventDispatcher();
var book = new Spellbook();
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
book,
new ChatLog(),
clientTime: () => 100d);
byte[] payload = BuildEnchantment(
spellId: 42, layer: 3, duration: 60, caster: 0xBEEF,
startTime: 10, lastDegraded: 2, statModType: 0x00008000u);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.MagicUpdateEnchantment, payload))!.Value);
ActiveEnchantmentRecord record = Assert.Single(book.ActiveEnchantmentSnapshot);
Assert.Equal(110d, record.StartTime);
Assert.Equal(102d, record.LastTimeDegraded);
Assert.Equal(2u, record.Bucket);
Assert.Equal(60d, record.Duration);
}
[Fact]
public void WireAll_WeenieError_RoutesToChatLog()
{
@ -1064,4 +1090,27 @@ public sealed class GameEventWiringTests
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
}
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,
double duration,
uint caster,
double startTime,
double lastDegraded,
uint statModType)
{
byte[] payload = new byte[60];
int offset = 0;
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster);
WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded);
WriteU32(statModType); WriteU32(7); WriteF32(1.25f);
return payload;
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
}
}

View file

@ -42,37 +42,92 @@ public sealed class CastSpellTests
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u);
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
Assert.Equal(0x123403E1u, parsed);
}
[Fact]
public void ParseMagicUpdateEnchantment_RoundTrip()
{
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); // spellId
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 7u); // layerId
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(8), 300.0f); // duration
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0xBEEFu); // caster
byte[] payload = BuildEnchantment(spellId: 42, layer: 7, duration: 300, caster: 0xBEEF);
var parsed = GameEvents.ParseMagicUpdateEnchantment(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.SpellId);
Assert.Equal(7u, parsed.Value.LayerId);
Assert.Equal(300f, parsed.Value.Duration, 4);
Assert.Equal((ushort)42, parsed!.Value.SpellId);
Assert.Equal((ushort)7, parsed.Value.Layer);
Assert.Equal(300d, parsed.Value.Duration, 4);
Assert.Equal(0xBEEFu, parsed.Value.CasterGuid);
Assert.Equal(0x20u, parsed.Value.StatModType);
Assert.Equal(7u, parsed.Value.StatModKey);
Assert.Equal(1.25f, parsed.Value.StatModValue);
}
[Fact]
public void ParseMagicRemoveEnchantment_RoundTrip()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt16LittleEndian(payload, 42);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2), 7);
var parsed = GameEvents.ParseMagicRemoveEnchantment(payload);
Assert.NotNull(parsed);
Assert.Equal(7u, parsed!.Value.LayerId);
Assert.Equal(42u, parsed.Value.SpellId);
Assert.Equal((ushort)7, parsed!.Value.Layer);
Assert.Equal((ushort)42, parsed.Value.SpellId);
}
[Fact]
public void ParseMagicUpdateMultipleEnchantments_ReadsEveryRecord()
{
byte[] first = BuildEnchantment(42, 1, 60, 0xAA);
byte[] second = BuildEnchantment(43, 2, 120, 0xBB);
byte[] payload = new byte[4 + first.Length + second.Length];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
first.CopyTo(payload, 4);
second.CopyTo(payload, 4 + first.Length);
var parsed = GameEvents.ParseMagicUpdateMultipleEnchantments(payload);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Count);
Assert.Equal((ushort)42, parsed[0].SpellId);
Assert.Equal((ushort)43, parsed[1].SpellId);
}
[Fact]
public void ParseMagicUpdateEnchantment_RejectsTruncatedOptionalSpellSetId()
{
byte[] payload = BuildEnchantment(42, 1, 60, 0xAA);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
Assert.Null(GameEvents.ParseMagicUpdateEnchantment(payload));
}
[Fact]
public void ParseMagicLayeredSpellList_RejectsTruncation()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4), 42);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
Assert.Null(GameEvents.ParseMagicLayeredSpellList(payload));
}
private static byte[] BuildEnchantment(
ushort spellId, ushort layer, double duration, uint caster)
{
byte[] payload = new byte[60];
int offset = 0;
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
WriteU32(8); WriteF64(10); WriteF64(duration); WriteU32(caster);
WriteF32(0.1f); WriteF32(-1f); WriteF64(0);
WriteU32(0x20); WriteU32(7); WriteF32(1.25f);
return payload;
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
}
}

View file

@ -147,6 +147,31 @@ public sealed class ClientCommandRequestsTests
Assert.Equal(25u, Read(body, 16));
}
[Fact]
public void AddSpellFavorite_MatchesRetailThreeIntegerPayload()
{
byte[] body = ClientCommandRequests.BuildAddSpellFavorite(9u, 42u, 3, 7);
Assert.Equal(24, body.Length);
Assert.Equal(ClientCommandRequests.AddSpellFavoriteOpcode, Read(body, 8));
Assert.Equal(42u, Read(body, 12));
Assert.Equal(3u, Read(body, 16));
Assert.Equal(7u, Read(body, 20));
}
[Fact]
public void RemoveFavoriteAndFilter_MatchRetailPayloads()
{
byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6);
byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u);
Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8));
Assert.Equal(42u, Read(remove, 12));
Assert.Equal(6u, Read(remove, 16));
Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8));
Assert.Equal(0xA5u, Read(filter, 12));
}
[Fact]
public void LegacyFriendsCommand_IsAControlMessageWithoutGameActionEnvelope()
{

View file

@ -628,6 +628,22 @@ public sealed class CreateObjectTests
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
}
[Fact]
public void WeenieHeader_spellId_isPreservedForCasterEndowment()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000025u,
name: "Orb",
itemType: (uint)ItemType.Caster,
weenieFlags: 0x00400000u,
spellId: 0x0A6E);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x0A6Eu, parsed.Value.SpellId);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
@ -673,7 +689,8 @@ public sealed class CreateObjectTests
uint materialType = 0,
uint cooldownId = 0,
double cooldownDuration = 0,
uint petOwnerId = 0)
uint petOwnerId = 0,
ushort spellId = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -760,7 +777,7 @@ public sealed class CreateObjectTests
bytes.AddRange(tmp.ToArray());
}
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
// HouseRestrictions (0x04000000): not parameterized (zero entries).
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.

View file

@ -237,7 +237,8 @@ public sealed class GameEventDispatcherTests
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
Assert.Equal(0x123403E1u, parsed);
}
}

View file

@ -379,7 +379,7 @@ public sealed class PlayerDescriptionParserTests
writer.Write(0u); // option_flags = None — no further sections
writer.Write(0xDEADBEEFu); // options1 sentinel
// No more bytes — spellbook_filters is optional (defaults to 0).
// No more bytes — spellbook_filters is optional (retail defaults all on).
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
@ -392,7 +392,7 @@ public sealed class PlayerDescriptionParserTests
// pre-existing regression guard if they accidentally consume into
// the wrong field's wire bytes.
Assert.Equal(0u, parsed.Value.Options2);
Assert.Equal(0u, parsed.Value.SpellbookFilters);
Assert.Equal(0x3FFFu, parsed.Value.SpellbookFilters);
Assert.Empty(parsed.Value.HotbarSpells);
Assert.Empty(parsed.Value.DesiredComps);
Assert.True(parsed.Value.GameplayOptions.IsEmpty);

View file

@ -0,0 +1,91 @@
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
public sealed class EnchantmentRegistryProjectionTests
{
[Fact]
public void GetEnchantmentsInEffect_ExcludesVitaeAndCooldown()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 1u),
Enchantment(2u, 4u, category: 2u),
Enchantment(3u, 8u, category: 3u),
Enchantment(4u, 2u, category: 4u),
]);
Assert.Equal([1u, 4u], result.Select(record => record.SpellId));
}
[Fact]
public void GetEnchantmentsInEffect_CategoryDuel_PrefersHigherPower()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 1u),
Enchantment(2u, 1u, category: 12u, power: 7u),
]);
Assert.Equal(2u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_PowerComparisonUsesRetailSignedInt()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 1u),
Enchantment(2u, 1u, category: 12u, power: uint.MaxValue),
]);
Assert.Equal(1u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_EqualPower_PrefersLaterStartAndKeepsExactTie()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 7u, start: 10d),
Enchantment(2u, 1u, category: 12u, power: 7u, start: 20d),
Enchantment(3u, 2u, category: 12u, power: 7u, start: 20d),
]);
Assert.Equal(2u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_ReplacingWinner_AppendsInRetailOrder()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 10u, power: 1u),
Enchantment(2u, 1u, category: 20u, power: 1u),
Enchantment(3u, 2u, category: 10u, power: 2u),
]);
Assert.Equal([2u, 3u], result.Select(record => record.SpellId));
}
private static ActiveEnchantmentRecord Enchantment(
uint spellId,
uint bucket,
uint category,
uint power = 1u,
double start = 0d)
=> new(
SpellId: spellId,
LayerId: spellId,
Duration: 60d,
CasterGuid: 1u,
Bucket: bucket,
StartTime: start,
SpellCategory: category,
PowerLevel: power);
}

View file

@ -0,0 +1,59 @@
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
public sealed class RetailSpellFormulaTests
{
[Theory]
[InlineData(1u, 1)]
[InlineData(6u, 6)]
[InlineData(0x6Eu, 6)]
[InlineData(0x70u, 7)]
[InlineData(0xC0u, 7)]
[InlineData(0xC1u, 8)]
[InlineData(7u, 0)]
public void RoughHeuristic_UsesFormulaPowerComponent(uint component, int expected)
=> Assert.Equal(expected,
RetailSpellFormula.InqSpellLevelByRoughHeuristic([component]));
[Fact]
public void CustomizeForAccount_IsDeterministic_AndOnlyChangesTapers()
{
uint[] formula = [6u, 63u, 10u, 64u, 20u, 30u, 65u, 40u];
IReadOnlyList<uint> first = RetailSpellFormula.CustomizeForAccount(
formula, 3u, "testaccount");
IReadOnlyList<uint> second = RetailSpellFormula.CustomizeForAccount(
formula, 3u, "testaccount");
Assert.Equal(first, second);
Assert.Equal(formula[0], first[0]);
Assert.Equal(formula[1], first[1]);
Assert.Equal(formula[2], first[2]);
Assert.InRange(first[3], 63u, 74u);
Assert.Equal(formula[4], first[4]);
Assert.Equal(formula[5], first[5]);
Assert.InRange(first[6], 63u, 74u);
Assert.Equal(formula[7], first[7]);
}
[Theory]
[InlineData(1u, 1)]
[InlineData(2u, 2)]
[InlineData(3u, 3)]
[InlineData(0x6Eu, 3)]
[InlineData(0x70u, 4)]
[InlineData(0xC1u, 4)]
public void ScarabOnlyFormula_KeepsPowerAndAppendsRetailTaperCount(
uint powerComponent,
int expectedTapers)
{
IReadOnlyList<uint> result = RetailSpellFormula.InqScarabOnlyFormula(
[powerComponent, 63u, 10u, 64u]);
Assert.Equal(powerComponent, result[0]);
Assert.Equal(expectedTapers, result.Count(component => component == 0xBCu));
Assert.DoesNotContain(63u, result);
Assert.DoesNotContain(10u, result);
}
}

View file

@ -61,6 +61,72 @@ public sealed class SpellbookTests
Assert.Equal(600f, e.Duration, 4);
}
[Fact]
public void OnEnchantmentAdded_SameLayerDifferentSpell_RemainsDistinct()
{
var book = new Spellbook();
book.OnEnchantmentAdded(42, 7, 300f, 0);
book.OnEnchantmentAdded(43, 7, 600f, 0);
Assert.Equal(2, book.ActiveCount);
}
[Fact]
public void LiveEnchantments_NewIdentityInsertsAtRetailListHead()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void LiveEnchantment_ExistingIdentityRefreshKeepsListPosition()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ManifestEnchantments_PreserveReceivedListOrder()
{
var book = new Spellbook();
book.ReplaceManifest(
new Dictionary<uint, float>(),
[Effect(1u, layer: 1u, category: 12u), Effect(2u, layer: 2u, category: 12u)],
[],
[],
0x3FFFu);
Assert.Equal(1u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ReplaceManifest_ReplacesFavoritesFiltersAndDesiredComponents()
{
var book = new Spellbook();
book.OnSpellLearned(999);
book.ReplaceManifest(
new Dictionary<uint, float> { [42] = 123f },
[new ActiveEnchantmentRecord(50, 2, 60, 1)],
[new uint[] { 42, 43 }],
[(100u, 25u)],
0xA5u);
Assert.False(book.Knows(999));
Assert.True(book.Knows(42));
Assert.Equal(new uint[] { 42, 43 }, book.GetFavorites(0));
Assert.Equal(25u, book.DesiredComponents[100]);
Assert.Equal(0xA5u, book.SpellbookFilters);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnEnchantmentRemoved_FiresEvent()
{
@ -76,18 +142,49 @@ public sealed class SpellbookTests
}
[Fact]
public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord()
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{
var book = new Spellbook();
book.OnEnchantmentAdded(1, 1, 100f, 0);
book.OnEnchantmentAdded(2, 2, 100f, 0);
book.OnEnchantmentAdded(3, 3, 100f, 0);
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, Bucket: 4));
book.OnEnchantmentAdded(new(5, 5, 100, 0, Bucket: 8));
int removals = 0;
book.EnchantmentRemoved += _ => removals++;
book.OnPurgeAll();
Assert.Equal(3, removals);
Assert.Equal(0, book.ActiveCount);
Assert.Equal(2, removals);
Assert.Equal(3, book.ActiveCount);
Assert.Equal([3u, 4u, 5u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
[Fact]
public void OnPurgeBad_UsesStatModBeneficialFlag_AndPreservesPermanent()
{
const uint Beneficial = 0x02000000u;
var book = new Spellbook();
book.OnEnchantmentAdded(new(1, 1, 100, 0, StatModType: 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, StatModType: Beneficial, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, StatModType: 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, StatModType: 0, Bucket: 8));
book.OnPurgeBadEnchantments();
Assert.Equal([2u, 3u, 4u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
private static ActiveEnchantmentRecord Effect(uint spellId, uint layer, uint category)
=> new(
SpellId: spellId,
LayerId: layer,
Duration: 60d,
CasterGuid: 1u,
Bucket: 1u,
StartTime: 10d,
SpellCategory: category,
PowerLevel: 7u);
}

View file

@ -38,6 +38,47 @@ public class InputDispatcherIsActionHeldTests
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_CombatBindingOnSameChord_ShadowsGamePolling()
{
var (dispatcher, kb, _, bindings) = Build();
var chord = new KeyChord(Key.W, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.MovementForward));
bindings.Add(new Binding(
chord,
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
dispatcher.SetCombatScope(null);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.False(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_ExplicitShiftCombatChord_ShadowsBareMovementFallback()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.Shift),
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.Shift);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_returns_false_when_no_binding_for_action()
{

View file

@ -82,6 +82,44 @@ public class InputDispatcherTests
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void Magic_combat_scope_shadows_game_binding_for_same_chord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_1));
bindings.Add(new Binding(chord, InputAction.UseSpellSlot_1, Scope: InputScope.MagicCombat));
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
Assert.Equal(
[(InputAction.UseQuickSlot_1, ActivationType.Press),
(InputAction.UseSpellSlot_1, ActivationType.Press)],
fired);
}
[Fact]
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
Assert.Equal(
[(InputAction.CombatLowAttack, ActivationType.Press),
(InputAction.CombatLowAttack, ActivationType.Release)],
fired);
}
[Fact]
public void PushScope_changes_ActiveScope()
{
@ -149,6 +187,40 @@ public class InputDispatcherTests
Assert.Empty(fired); // no longer held
}
[Fact]
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
bindings.Add(new Binding(
new KeyChord(Key.End, ModifierMask.None),
InputAction.CombatHighAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
kb.EmitKeyDown(Key.End, ModifierMask.None);
fired.Clear();
bool changed = false;
dispatcher.Fired += (_, activation) =>
{
if (activation != ActivationType.Hold || changed)
return;
changed = true;
dispatcher.SetCombatScope(InputScope.MagicCombat);
};
dispatcher.Tick();
Assert.Single(fired, entry => entry.Item2 == ActivationType.Hold);
Assert.Equal(2, fired.Count(entry => entry.Item2 == ActivationType.Release));
}
[Fact]
public void Release_binding_fires_only_on_KeyUp()
{

View file

@ -49,7 +49,8 @@ public class KeyBindingsJsonTests
Assert.Contains(loaded.All, x =>
x.Chord == b.Chord
&& x.Action == b.Action
&& x.Activation == b.Activation);
&& x.Activation == b.Activation
&& x.Scope == b.Scope);
}
}
finally

View file

@ -57,6 +57,20 @@ public class KeyBindingsTests
Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
}
[Fact]
public void RetailDefaults_resolve_shared_number_key_by_scope()
{
var bindings = KeyBindings.RetailDefaults();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
Assert.Equal(
InputAction.UseQuickSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.Game)?.Action);
Assert.Equal(
InputAction.UseSpellSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.MagicCombat)?.Action);
}
[Fact]
public void ForAction_returns_all_chords_bound_to_action()
{