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>
168 lines
6.5 KiB
C#
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);
|