diff --git a/src/AcDream.Core.Net/Messages/CastSpellRequest.cs b/src/AcDream.Core.Net/Messages/CastSpellRequest.cs new file mode 100644 index 0000000..1e5f25d --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CastSpellRequest.cs @@ -0,0 +1,64 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound Magic_CastSpell GameActions — targeted and untargeted +/// variants. From r01 §2 + r08 §3 opcodes 0x0048 / 0x004A. +/// +/// +/// Wire layouts (inside the 0xF7B1 GameAction envelope): +/// +/// +/// +/// Untargeted (0x0048): +/// +/// u32 0xF7B1 +/// u32 gameActionSequence +/// u32 0x0048 +/// u32 spellId +/// +/// +/// +/// +/// Targeted (0x004A): +/// +/// u32 0xF7B1 +/// u32 gameActionSequence +/// u32 0x004A +/// u32 targetGuid +/// u32 spellId +/// +/// +/// +public static class CastSpellRequest +{ + public const uint GameActionEnvelope = 0xF7B1u; + public const uint UntargetedSubOpcode = 0x0048u; + public const uint TargetedSubOpcode = 0x004Au; + + /// Build an untargeted cast (buff self, recall, self-heal, etc). + public static byte[] BuildUntargeted(uint gameActionSequence, uint spellId) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), UntargetedSubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId); + return body; + } + + /// + /// Build a targeted cast (projectile attacks, target buffs, debuffs). + /// + public static byte[] BuildTargeted(uint gameActionSequence, uint targetGuid, uint spellId) + { + byte[] body = new byte[20]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedSubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), spellId); + return body; + } +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 8c35872..5f7dcc3 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -264,6 +264,63 @@ public static class GameEvents BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); } + // ── Spell enchantments ────────────────────────────────────────────────── + + /// + /// 0x02C3 MagicRemoveEnchantment — (layerId, spellId). + /// + public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId); + + public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new MagicRemoveEnchantment( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + } + + /// 0x01A8 MagicRemoveSpell — spell id removed from spellbook. + public static uint? ParseMagicRemoveSpell(ReadOnlySpan payload) + { + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); + } + + /// + /// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout + /// (ACE Enchantment.Pack) is ~80+ bytes of spell metadata + + /// stat mods. We expose the first few fields that drive the enchant + /// bar UI; the rest is available via the raw payload view. + /// + public readonly record struct EnchantmentSummary( + uint SpellId, + uint LayerId, + float Duration, + uint CasterGuid); + + public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan payload) + { + // Layout (ACE Enchantment.Pack): + // u32 spellId + // u32 layerId + // f32 duration + // u32 casterGuid + // ... (stat mods, category, power, etc.) + if (payload.Length < 16) return null; + return new EnchantmentSummary( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12))); + } + + /// + /// 0x02C7 MagicDispelEnchantment — (layerId, spellId). + /// Structure matches MagicRemoveEnchantment. + /// + public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan payload) + => ParseMagicRemoveEnchantment(payload); + // ── Appraise / identify ───────────────────────────────────────────────── /// 0x00C9 IdentifyObjectResponse header. diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs new file mode 100644 index 0000000..2025fa7 --- /dev/null +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace AcDream.Core.Spells; + +/// +/// Client-side spellbook mirror. Tracks which spells the player has +/// learned (UpdateSpell 0x02C1) + a parallel active-enchantment +/// table keyed by layer id (UpdateEnchantment 0x02C2 etc.). +/// +/// +/// The UI binds to the collection-changed events so the spellbook +/// panel + active-buff bar redraw automatically when the server +/// pushes changes. +/// +/// +public sealed class Spellbook +{ + private readonly HashSet _learnedSpells = new(); + private readonly ConcurrentDictionary _activeByLayer = new(); + + /// Fires when a spell is added to the player's spellbook. + public event Action? SpellLearned; + + /// Fires when a spell is removed (rare — usually on respec / admin). + public event Action? SpellForgotten; + + /// Fires when an enchantment is added / refreshed. + public event Action? EnchantmentAdded; + + /// Fires when an enchantment is removed (expired / dispelled). + public event Action? EnchantmentRemoved; + + /// All currently learned spell ids. + public IReadOnlyCollection LearnedSpells => _learnedSpells; + + /// All currently-active enchantments. + public IEnumerable ActiveEnchantments => _activeByLayer.Values; + + public int LearnedCount => _learnedSpells.Count; + public int ActiveCount => _activeByLayer.Count; + + public bool Knows(uint spellId) => _learnedSpells.Contains(spellId); + + // ── Inbound handlers ───────────────────────────────────────────────────── + + /// 0x02C1 MagicUpdateSpell: learn a spell. + public void OnSpellLearned(uint spellId) + { + if (_learnedSpells.Add(spellId)) + SpellLearned?.Invoke(spellId); + } + + /// 0x01A8 MagicRemoveSpell: forget a spell. + public void OnSpellForgotten(uint spellId) + { + if (_learnedSpells.Remove(spellId)) + SpellForgotten?.Invoke(spellId); + } + + /// 0x02C2 MagicUpdateEnchantment: enchantment added / refreshed. + public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid) + { + var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid); + _activeByLayer[layerId] = record; + EnchantmentAdded?.Invoke(record); + } + + /// 0x02C3 / 0x02C7 MagicRemove/DispelEnchantment. + public void OnEnchantmentRemoved(uint layerId, uint spellId) + { + if (_activeByLayer.TryRemove(layerId, out var record)) + EnchantmentRemoved?.Invoke(record); + else + EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0)); + } + + /// 0x02C6 MagicPurgeEnchantments: clear all active buffs. + public void OnPurgeAll() + { + foreach (var rec in _activeByLayer.Values) + EnchantmentRemoved?.Invoke(rec); + _activeByLayer.Clear(); + } + + public void Clear() + { + _learnedSpells.Clear(); + _activeByLayer.Clear(); + } +} + +/// +/// Summary of one active enchantment layer on the player. Richer detail +/// (stat mods, category, power) requires the full +/// struct — this record is the wire-slim version surfaced by the +/// event. +/// +public readonly record struct ActiveEnchantmentRecord( + uint SpellId, + uint LayerId, + float Duration, + uint CasterGuid); diff --git a/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs new file mode 100644 index 0000000..f1253ff --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CastSpellTests +{ + [Fact] + public void BuildUntargeted_EmitsCorrectWireBytes() + { + byte[] body = CastSpellRequest.BuildUntargeted(gameActionSequence: 5, spellId: 0x3E1); + + Assert.Equal(16, body.Length); + Assert.Equal(CastSpellRequest.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(5u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(CastSpellRequest.UntargetedSubOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x3E1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildTargeted_EmitsCorrectWireBytes() + { + byte[] body = CastSpellRequest.BuildTargeted( + gameActionSequence: 7, targetGuid: 0xAAAAu, spellId: 0x3E1); + + Assert.Equal(20, body.Length); + Assert.Equal(CastSpellRequest.TargetedSubOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xAAAAu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(0x3E1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + } + + [Fact] + public void ParseMagicUpdateSpell_RoundTrip() + { + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); + Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload)); + } + + [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 + + 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(0xBEEFu, parsed.Value.CasterGuid); + } + + [Fact] + public void ParseMagicRemoveEnchantment_RoundTrip() + { + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId + + var parsed = GameEvents.ParseMagicRemoveEnchantment(payload); + Assert.NotNull(parsed); + Assert.Equal(7u, parsed!.Value.LayerId); + Assert.Equal(42u, parsed.Value.SpellId); + } +} diff --git a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs new file mode 100644 index 0000000..a85e504 --- /dev/null +++ b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs @@ -0,0 +1,93 @@ +using AcDream.Core.Spells; +using Xunit; + +namespace AcDream.Core.Tests.Spells; + +public sealed class SpellbookTests +{ + [Fact] + public void OnSpellLearned_FiresEvent_Idempotent() + { + var book = new Spellbook(); + int events = 0; + book.SpellLearned += _ => events++; + + book.OnSpellLearned(0x3E1); // Flame Bolt I + book.OnSpellLearned(0x3E1); // duplicate — no event + + Assert.Equal(1, events); + Assert.Equal(1, book.LearnedCount); + Assert.True(book.Knows(0x3E1)); + } + + [Fact] + public void OnSpellForgotten_FiresEvent() + { + var book = new Spellbook(); + book.OnSpellLearned(0x3E1); + + uint seen = 0; + book.SpellForgotten += id => seen = id; + + book.OnSpellForgotten(0x3E1); + Assert.Equal(0x3E1u, seen); + Assert.False(book.Knows(0x3E1)); + } + + [Fact] + public void OnEnchantmentAdded_FiresEvent_StoresByLayer() + { + var book = new Spellbook(); + ActiveEnchantmentRecord added = default; + book.EnchantmentAdded += r => added = r; + + book.OnEnchantmentAdded(spellId: 42, layerId: 7, duration: 300f, casterGuid: 0xBEEF); + + Assert.Equal(42u, added.SpellId); + Assert.Equal(7u, added.LayerId); + Assert.Equal(300f, added.Duration, 4); + Assert.Equal(1, book.ActiveCount); + } + + [Fact] + public void OnEnchantmentAdded_SameLayer_Replaces() + { + var book = new Spellbook(); + book.OnEnchantmentAdded(42, 7, 300f, 0); + book.OnEnchantmentAdded(42, 7, 600f, 0); // refresh + + Assert.Equal(1, book.ActiveCount); + foreach (var e in book.ActiveEnchantments) + Assert.Equal(600f, e.Duration, 4); + } + + [Fact] + public void OnEnchantmentRemoved_FiresEvent() + { + var book = new Spellbook(); + book.OnEnchantmentAdded(42, 7, 300f, 0); + + uint removedSpell = 0; + book.EnchantmentRemoved += r => removedSpell = r.SpellId; + + book.OnEnchantmentRemoved(7, 42); + Assert.Equal(42u, removedSpell); + Assert.Equal(0, book.ActiveCount); + } + + [Fact] + public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord() + { + var book = new Spellbook(); + book.OnEnchantmentAdded(1, 1, 100f, 0); + book.OnEnchantmentAdded(2, 2, 100f, 0); + book.OnEnchantmentAdded(3, 3, 100f, 0); + + int removals = 0; + book.EnchantmentRemoved += _ => removals++; + + book.OnPurgeAll(); + Assert.Equal(3, removals); + Assert.Equal(0, book.ActiveCount); + } +}