feat(spells): Phase E.5 CastSpellRequest + Spellbook/enchantment state

Completes the client-side spell loop on top of Phase F.1. Player can
send cast requests; spellbook + active-enchantment state mirrors what
the server broadcasts.

Wire layer:
- CastSpellRequest (C→S, inside 0xF7B1 GameAction):
  - BuildUntargeted (0x0048): self-buffs, recalls, heal-self — 16 bytes.
  - BuildTargeted (0x004A): projectile attacks, target buffs/debuffs — 20 bytes.
- GameEvents parsers added:
  - 0x02C1 MagicUpdateSpell: spell-id → spellbook.
  - 0x01A8 MagicRemoveSpell.
  - 0x02C2 MagicUpdateEnchantment: spellId + layerId + duration + casterGuid
    (summary head; full stat-mod body deferred).
  - 0x02C3 MagicRemoveEnchantment: (layerId, spellId).
  - 0x02C7 MagicDispelEnchantment: same shape.

Core layer:
- Spellbook: learned-spell set + active-enchantment-by-layer dict
  with events (SpellLearned, SpellForgotten, EnchantmentAdded,
  EnchantmentRemoved). Duplicate learn is idempotent. Same-layer
  add refreshes duration. Purge fires per-record remove for UI
  cleanup.
- ActiveEnchantmentRecord: (SpellId, LayerId, Duration, CasterGuid).

Tests (10 new):
- CastSpellRequest untargeted (16 bytes) + targeted (20 bytes) wire encoding.
- GameEvents: MagicUpdateSpell, MagicUpdateEnchantment,
  MagicRemoveEnchantment round-trip.
- Spellbook: learn idempotent, forget, add/refresh enchantment,
  remove fires event, purge-all clears + fires per-record.

Build green, 555 tests pass (up from 544).

Ref: r01 §2 (wire casts), §3 (cast state machine), §5 (stacking rules).
Ref: r08 §3 opcodes 0x0048/0x004A, §4 opcodes 0x01A8/0x02C1-0x02C8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 17:00:32 +02:00
parent 2e3f9d7a04
commit c95aedcd4a
5 changed files with 396 additions and 0 deletions

View file

@ -0,0 +1,64 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound <c>Magic_CastSpell</c> GameActions — targeted and untargeted
/// variants. From r01 §2 + r08 §3 opcodes 0x0048 / 0x004A.
///
/// <para>
/// Wire layouts (inside the <c>0xF7B1</c> GameAction envelope):
/// </para>
///
/// <para>
/// Untargeted (<c>0x0048</c>):
/// <code>
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x0048
/// u32 spellId
/// </code>
/// </para>
///
/// <para>
/// Targeted (<c>0x004A</c>):
/// <code>
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x004A
/// u32 targetGuid
/// u32 spellId
/// </code>
/// </para>
/// </summary>
public static class CastSpellRequest
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint UntargetedSubOpcode = 0x0048u;
public const uint TargetedSubOpcode = 0x004Au;
/// <summary>Build an untargeted cast (buff self, recall, self-heal, etc).</summary>
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;
}
/// <summary>
/// Build a targeted cast (projectile attacks, target buffs, debuffs).
/// </summary>
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;
}
}

View file

@ -264,6 +264,63 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
// ── Spell enchantments ──────────────────────────────────────────────────
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
public static uint? ParseMagicRemoveSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
/// (ACE <c>Enchantment.Pack</c>) 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.
/// </summary>
public readonly record struct EnchantmentSummary(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> 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)));
}
/// <summary>
/// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
/// Structure matches MagicRemoveEnchantment.
/// </summary>
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>

View file

@ -0,0 +1,104 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Client-side spellbook mirror. Tracks which spells the player has
/// learned (<c>UpdateSpell</c> 0x02C1) + a parallel active-enchantment
/// table keyed by layer id (<c>UpdateEnchantment</c> 0x02C2 etc.).
///
/// <para>
/// The UI binds to the collection-changed events so the spellbook
/// panel + active-buff bar redraw automatically when the server
/// pushes changes.
/// </para>
/// </summary>
public sealed class Spellbook
{
private readonly HashSet<uint> _learnedSpells = new();
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
public event Action<uint>? SpellLearned;
/// <summary>Fires when a spell is removed (rare — usually on respec / admin).</summary>
public event Action<uint>? SpellForgotten;
/// <summary>Fires when an enchantment is added / refreshed.</summary>
public event Action<ActiveEnchantmentRecord>? EnchantmentAdded;
/// <summary>Fires when an enchantment is removed (expired / dispelled).</summary>
public event Action<ActiveEnchantmentRecord>? EnchantmentRemoved;
/// <summary>All currently learned spell ids.</summary>
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells;
/// <summary>All currently-active enchantments.</summary>
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeByLayer.Values;
public int LearnedCount => _learnedSpells.Count;
public int ActiveCount => _activeByLayer.Count;
public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
// ── Inbound handlers ─────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
public void OnSpellLearned(uint spellId)
{
if (_learnedSpells.Add(spellId))
SpellLearned?.Invoke(spellId);
}
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
public void OnSpellForgotten(uint spellId)
{
if (_learnedSpells.Remove(spellId))
SpellForgotten?.Invoke(spellId);
}
/// <summary>0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.</summary>
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);
}
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
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));
}
/// <summary>0x02C6 MagicPurgeEnchantments: clear all active buffs.</summary>
public void OnPurgeAll()
{
foreach (var rec in _activeByLayer.Values)
EnchantmentRemoved?.Invoke(rec);
_activeByLayer.Clear();
}
public void Clear()
{
_learnedSpells.Clear();
_activeByLayer.Clear();
}
}
/// <summary>
/// Summary of one active enchantment layer on the player. Richer detail
/// (stat mods, category, power) requires the full <see cref="ActiveBuff"/>
/// struct — this record is the wire-slim version surfaced by the
/// <see cref="Spellbook.EnchantmentAdded"/> event.
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);

View file

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

View file

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