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:
parent
2e3f9d7a04
commit
c95aedcd4a
5 changed files with 396 additions and 0 deletions
64
src/AcDream.Core.Net/Messages/CastSpellRequest.cs
Normal file
64
src/AcDream.Core.Net/Messages/CastSpellRequest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
104
src/AcDream.Core/Spells/Spellbook.cs
Normal file
104
src/AcDream.Core/Spells/Spellbook.cs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue