acdream/src/AcDream.Core/Spells/Spellbook.cs
Erik bb5003a849 feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
  u16 spell_id, layer, spell_category, has_spell_set_id
  u32 power_level
  f64 start_time, duration
  u32 caster_guid
  f32 degrade_modifier, degrade_limit
  f64 last_time_degraded
  u32 stat_mod_type, stat_mod_key
  f32 stat_mod_value
  [u32 spell_set_id]?
  + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)

EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.

Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:

  Bucket 1 (Multiplicative): multiplier *= val
  Bucket 2 (Additive):       additive += val
  Bucket 8 (Vitae):          multiplier *= val (applied last)
  Bucket 4 (Cooldown):       skipped (not a vital mod)

ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.

Tests: 7 new (834 -> 841):
  - PlayerDescriptionParserTests (2): enchantment block schema with
    multiplicative + additive buckets, Vitae singleton.
  - EnchantmentMathTests (5): multiplicative buffs aggregate, additive
    buffs sum, stat-key mismatch filters out, Vitae applied
    multiplicatively, family-stacking picks higher spell-id.

Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).

Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:01:22 +02:00

168 lines
6.5 KiB
C#

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();
private readonly SpellTable _table;
/// <summary>
/// Build a Spellbook with an optional <see cref="SpellTable"/>
/// metadata source. When provided, <see cref="TryGetMetadata"/>
/// returns the static spell descriptor (name / school / family /
/// icon / mana / duration / etc.). When absent (back-compat for
/// existing constructors / tests), <see cref="TryGetMetadata"/>
/// always returns <c>false</c>.
/// </summary>
public Spellbook(SpellTable? table = null)
{
_table = table ?? SpellTable.Empty;
}
/// <summary>
/// Look up static spell metadata. Closes ISSUES.md #11 — feeds the
/// buff bar UI labels + icons + the family-stacking aggregation in
/// <c>EnchantmentMath</c> (issue #6).
/// </summary>
public bool TryGetMetadata(uint spellId, out SpellMetadata meta) =>
_table.TryGet(spellId, out meta);
/// <summary>The spell-metadata table this spellbook was built with.
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
public SpellTable Metadata => _table;
/// <summary>
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
/// over <see cref="ActiveEnchantments"/> through
/// <see cref="EnchantmentMath.GetMod"/> with family-stacking dedup
/// from the spell metadata table. Returns
/// <see cref="EnchantmentMath.VitalMod.Identity"/> when no buffs
/// apply (or when no SpellTable was wired).
/// </summary>
public EnchantmentMath.VitalMod GetVitalMod(uint statKey) =>
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
/// <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>
/// Issue #7 / #12 — accept a fully-populated record from
/// <c>PlayerDescription</c>'s enchantment block (which carries
/// the StatMod triad + bucket). Used when the wire-format extension
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{
_activeByLayer[record.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. The
/// optional StatMod fields (issue #12) carry the wire-level
/// `_smod` triad <c>(type, key, val)</c> when available — only
/// `PlayerDescription`'s enchantment block currently populates these
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
/// `MagicUpdateEnchantment` events still produce records with these
/// fields null until the wire parser is extended.
///
/// <para>
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
/// the retail <c>EnchantmentMask</c> classification.
/// </para>
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);