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:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
115
tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
Normal file
115
tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
Normal 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);
|
||||
}
|
||||
119
tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
Normal file
119
tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
198
tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
Normal file
198
tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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"}"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
2719
tests/AcDream.App.Tests/UI/Layout/fixtures/indicators_21000071.json
Normal file
2719
tests/AcDream.App.Tests/UI/Layout/fixtures/indicators_21000071.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8940,7 +8940,7 @@
|
|||
},
|
||||
"DefaultStateId": 1,
|
||||
"FontDid": 1073741826,
|
||||
"HJustify": 1,
|
||||
"HJustify": 0,
|
||||
"VJustify": 1,
|
||||
"FontColor": {
|
||||
"X": 1,
|
||||
|
|
|
|||
|
|
@ -8151,7 +8151,7 @@
|
|||
},
|
||||
"DefaultStateId": 1,
|
||||
"FontDid": 1073741826,
|
||||
"HJustify": 1,
|
||||
"HJustify": 0,
|
||||
"VJustify": 1,
|
||||
"FontColor": {
|
||||
"X": 1,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
59
tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs
Normal file
59
tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue