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
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