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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue