Unify the toolbar shortcut manager with Runtime inventory state, route retail-ordered shortcut and spellbook command effects through the canonical owners, and make retained controllers borrow those exact instances. Remove the item-interaction transaction fallback and add graphical/no-window parity plus failure-safe terminal ownership-ledger coverage. Co-authored-by: Codex <codex@openai.com>
583 lines
21 KiB
C#
583 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
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
|
|
{
|
|
public const uint CooldownSpellOffset = 0x8000u;
|
|
public const uint CooldownBucket = 8u;
|
|
|
|
private readonly Dictionary<uint, float> _learnedSpells = new();
|
|
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
|
|
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
|
|
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new();
|
|
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
|
|
.Select(_ => new List<uint>()).ToArray();
|
|
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
|
private SpellTable _table;
|
|
private bool _metadataInstalled;
|
|
private uint _spellbookFilters = 0x3FFFu;
|
|
|
|
/// <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;
|
|
_metadataInstalled = table is not null;
|
|
}
|
|
|
|
/// <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>
|
|
/// Installs the production metadata table after DatCollection opens.
|
|
/// GameWindow constructs client state before Silk's OnLoad callback, while
|
|
/// portal.dat becomes available inside OnLoad. The table may be installed
|
|
/// once; replacing a populated catalog would invalidate active stacking and
|
|
/// presentation decisions.
|
|
/// </summary>
|
|
public void InstallMetadata(SpellTable table)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(table);
|
|
if (_metadataInstalled)
|
|
{
|
|
if (ReferenceEquals(_table, table)) return;
|
|
throw new InvalidOperationException("Spell metadata is already installed.");
|
|
}
|
|
|
|
_table = table;
|
|
_metadataInstalled = true;
|
|
if (_learnedSpells.Count != 0) NotifySpellbookChanged();
|
|
if (_activeById.Count != 0) NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
if (_vitalModCache.TryGetValue(
|
|
statKey,
|
|
out EnchantmentMath.VitalMod cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
EnchantmentMath.VitalMod calculated =
|
|
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
|
|
_vitalModCache.Add(statKey, calculated);
|
|
return calculated;
|
|
}
|
|
|
|
/// <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>Fires when any manifest-backed magic UI state changes.</summary>
|
|
public event Action? StateChanged;
|
|
|
|
/// <summary>Fires when learned spells, favorites, or spellbook filters change.</summary>
|
|
public event Action? SpellbookChanged;
|
|
|
|
/// <summary>Fires when the active enchantment set changes.</summary>
|
|
public event Action? EnchantmentsChanged;
|
|
|
|
/// <summary>Fires when the desired spell-component levels change.</summary>
|
|
public event Action? DesiredComponentsChanged;
|
|
|
|
/// <summary>All currently learned spell ids.</summary>
|
|
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells.Keys;
|
|
|
|
/// <summary>Stable learned-spell snapshot, including server spell power.</summary>
|
|
public IReadOnlyList<LearnedSpellRecord> LearnedSpellSnapshot => _learnedSpells
|
|
.Select(pair => new LearnedSpellRecord(pair.Key, pair.Value))
|
|
.OrderBy(spell => spell.SpellId)
|
|
.ToArray();
|
|
|
|
/// <summary>All currently-active enchantments.</summary>
|
|
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeById.Values;
|
|
|
|
public IReadOnlyList<ActiveEnchantmentRecord> ActiveEnchantmentSnapshot =>
|
|
_activeById.Values.OrderBy(record => record.Identity).ToArray();
|
|
|
|
/// <summary>
|
|
/// Retail ordinary-effect projection after mult/add filtering and the
|
|
/// spell-category duel. This is the source for effects-list rows; retail's
|
|
/// indicator counts scan the raw mult/add records before this projection.
|
|
/// </summary>
|
|
public IReadOnlyList<ActiveEnchantmentRecord> EnchantmentsInEffectSnapshot
|
|
{
|
|
get
|
|
{
|
|
var ordered = new List<ActiveEnchantmentRecord>();
|
|
AppendBucket(bucket: 1u, ordered);
|
|
AppendBucket(bucket: 2u, ordered);
|
|
return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered);
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<uint> GetFavorites(int tabIndex)
|
|
{
|
|
if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty<uint>();
|
|
return _favoriteSpells[tabIndex].ToArray();
|
|
}
|
|
|
|
public IReadOnlyDictionary<uint, uint> DesiredComponents => _desiredComponents;
|
|
|
|
public uint SpellbookFilters => _spellbookFilters;
|
|
|
|
public int LearnedCount => _learnedSpells.Count;
|
|
public int ActiveCount => _activeById.Count;
|
|
public bool HasCooldownEnchantments =>
|
|
_enchantmentOrderByBucket.TryGetValue(
|
|
CooldownBucket,
|
|
out List<uint>? order)
|
|
&& order.Count != 0;
|
|
|
|
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);
|
|
|
|
// ── Inbound handlers ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
|
|
public void OnSpellLearned(uint spellId, float power = 0f)
|
|
{
|
|
if (_learnedSpells.TryAdd(spellId, power))
|
|
{
|
|
SpellLearned?.Invoke(spellId);
|
|
NotifySpellbookChanged();
|
|
}
|
|
else if (_learnedSpells[spellId] != power)
|
|
{
|
|
_learnedSpells[spellId] = power;
|
|
NotifySpellbookChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
|
|
public void OnSpellForgotten(uint spellId)
|
|
{
|
|
if (_learnedSpells.Remove(spellId))
|
|
{
|
|
SpellForgotten?.Invoke(spellId);
|
|
NotifySpellbookChanged();
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
UpsertLiveEnchantment(record);
|
|
EnchantmentAdded?.Invoke(record);
|
|
NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
UpsertLiveEnchantment(record);
|
|
EnchantmentAdded?.Invoke(record);
|
|
NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
|
|
public void OnEnchantmentRemoved(uint layerId, uint spellId)
|
|
{
|
|
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId);
|
|
if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record))
|
|
{
|
|
EnchantmentRemoved?.Invoke(record);
|
|
NotifyEnchantmentsChanged();
|
|
}
|
|
}
|
|
|
|
public void OnEnchantmentsAdded(IEnumerable<ActiveEnchantmentRecord> records)
|
|
{
|
|
foreach (ActiveEnchantmentRecord record in records)
|
|
{
|
|
UpsertLiveEnchantment(record);
|
|
EnchantmentAdded?.Invoke(record);
|
|
}
|
|
NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells)
|
|
{
|
|
bool changed = false;
|
|
foreach ((uint spellId, uint layer) in spells)
|
|
{
|
|
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer);
|
|
if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue;
|
|
changed = true;
|
|
EnchantmentRemoved?.Invoke(record);
|
|
}
|
|
if (changed) NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>CEnchantmentRegistry::OnCooldown @ 0x005943C0</c>.
|
|
/// Scans the cooldown list head-to-tail, stops at the first shared-group
|
|
/// match, and removes that node when its monotonic lifetime has expired.
|
|
/// </summary>
|
|
public bool OnCooldown(
|
|
uint cooldownId,
|
|
double currentTime,
|
|
out double remaining)
|
|
{
|
|
remaining = 0d;
|
|
if (cooldownId == 0u
|
|
|| !_enchantmentOrderByBucket.TryGetValue(
|
|
CooldownBucket,
|
|
out List<uint>? order))
|
|
return false;
|
|
|
|
uint cooldownSpellId = unchecked(cooldownId + CooldownSpellOffset);
|
|
for (int index = 0; index < order.Count; index++)
|
|
{
|
|
uint identity = order[index];
|
|
if (!_activeById.TryGetValue(
|
|
identity,
|
|
out ActiveEnchantmentRecord record)
|
|
|| (record.SpellId & 0xFFFFu) != cooldownSpellId)
|
|
continue;
|
|
|
|
remaining = record.Duration + record.StartTime - currentTime;
|
|
if (remaining > 0d)
|
|
return true;
|
|
|
|
RemoveActiveEnchantment(identity, out _);
|
|
EnchantmentRemoved?.Invoke(record);
|
|
NotifyEnchantmentsChanged();
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
|
|
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
|
|
/// and permanent enchantments live in separate registries and survive.
|
|
/// </summary>
|
|
public void OnPurgeAll()
|
|
{
|
|
ActiveEnchantmentRecord[] removed = _activeById.Values
|
|
.Where(IsPurgeable)
|
|
.ToArray();
|
|
foreach (ActiveEnchantmentRecord record in removed)
|
|
RemoveActiveEnchantment(record.Identity, out _);
|
|
foreach (var rec in removed)
|
|
EnchantmentRemoved?.Invoke(rec);
|
|
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
public void OnPurgeBadEnchantments()
|
|
{
|
|
// Retail PurgeBadEnchantments (0x00594E00) tests the StatMod
|
|
// Beneficial bit directly; spell-table labels do not participate.
|
|
const uint BeneficialStatMod = 0x02000000u;
|
|
ActiveEnchantmentRecord[] removed = _activeById.Values
|
|
.Where(record => IsPurgeable(record)
|
|
&& (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0)
|
|
.ToArray();
|
|
foreach (ActiveEnchantmentRecord record in removed)
|
|
RemoveActiveEnchantment(record.Identity, out _);
|
|
foreach (ActiveEnchantmentRecord record in removed)
|
|
EnchantmentRemoved?.Invoke(record);
|
|
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
|
}
|
|
|
|
private static bool IsPurgeable(ActiveEnchantmentRecord record) =>
|
|
(record.Bucket is 1u or 2u) && record.Duration != -1f;
|
|
|
|
/// <summary>
|
|
/// PlayerDescription is a complete character manifest. Replace every
|
|
/// magic collection atomically so relogging or switching characters cannot
|
|
/// retain state from the prior snapshot.
|
|
/// </summary>
|
|
public void ReplaceManifest(
|
|
IReadOnlyDictionary<uint, float> learnedSpells,
|
|
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
|
IReadOnlyList<IReadOnlyList<uint>> favorites,
|
|
IReadOnlyList<(uint Id, uint Amount)> desiredComponents,
|
|
uint spellbookFilters)
|
|
{
|
|
_learnedSpells.Clear();
|
|
foreach ((uint spellId, float power) in learnedSpells)
|
|
_learnedSpells[spellId] = power;
|
|
|
|
_activeById.Clear();
|
|
_enchantmentOrderByBucket.Clear();
|
|
_vitalModCache.Clear();
|
|
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
|
UpsertManifestEnchantment(enchantment);
|
|
|
|
for (int i = 0; i < _favoriteSpells.Length; i++)
|
|
{
|
|
_favoriteSpells[i].Clear();
|
|
if (i < favorites.Count)
|
|
_favoriteSpells[i].AddRange(favorites[i]);
|
|
}
|
|
|
|
_desiredComponents.Clear();
|
|
foreach ((uint id, uint amount) in desiredComponents)
|
|
_desiredComponents[id] = amount;
|
|
_spellbookFilters = spellbookFilters;
|
|
SpellbookChanged?.Invoke();
|
|
EnchantmentsChanged?.Invoke();
|
|
DesiredComponentsChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
public void SetFavorite(int tabIndex, int position, uint spellId)
|
|
{
|
|
if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return;
|
|
List<uint> tab = _favoriteSpells[tabIndex];
|
|
if (position > tab.Count) position = tab.Count;
|
|
tab.Remove(spellId);
|
|
tab.Insert(Math.Min(position, tab.Count), spellId);
|
|
NotifySpellbookChanged();
|
|
}
|
|
|
|
public void RemoveFavorite(int tabIndex, uint spellId)
|
|
{
|
|
if ((uint)tabIndex >= _favoriteSpells.Length) return;
|
|
if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged();
|
|
}
|
|
|
|
public void SetSpellbookFilters(uint filters)
|
|
{
|
|
if (_spellbookFilters == filters) return;
|
|
_spellbookFilters = filters;
|
|
NotifySpellbookChanged();
|
|
}
|
|
|
|
public void SetDesiredComponent(uint componentId, uint amount)
|
|
{
|
|
bool exists = _desiredComponents.TryGetValue(
|
|
componentId,
|
|
out uint current);
|
|
if (exists && current == amount) return;
|
|
// PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a
|
|
// real table value; only ClearDesiredCompList destroys the entry set.
|
|
_desiredComponents[componentId] = amount;
|
|
DesiredComponentsChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Destroy the complete desired-component list, matching
|
|
/// <c>PlayerModule::ClearDesiredCompList @ 0x005D2A00</c>.
|
|
/// </summary>
|
|
public void ClearDesiredComponents()
|
|
{
|
|
if (_desiredComponents.Count == 0) return;
|
|
_desiredComponents.Clear();
|
|
DesiredComponentsChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
_learnedSpells.Clear();
|
|
_activeById.Clear();
|
|
_enchantmentOrderByBucket.Clear();
|
|
_vitalModCache.Clear();
|
|
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
|
|
_desiredComponents.Clear();
|
|
_spellbookFilters = 0x3FFFu;
|
|
SpellbookChanged?.Invoke();
|
|
EnchantmentsChanged?.Invoke();
|
|
DesiredComponentsChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
private void NotifySpellbookChanged()
|
|
{
|
|
SpellbookChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
private void NotifyEnchantmentsChanged()
|
|
{
|
|
_vitalModCache.Clear();
|
|
EnchantmentsChanged?.Invoke();
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
private void AppendBucket(uint bucket, List<ActiveEnchantmentRecord> destination)
|
|
{
|
|
if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order)) return;
|
|
foreach (uint identity in order)
|
|
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record)
|
|
&& record.Bucket == bucket)
|
|
destination.Add(record);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>AddEnchantmentToList @ 0x00593DF0</c> inserts a new live
|
|
/// mult/add record at the list head. Refreshing an existing identity keeps
|
|
/// its node position; moving buckets creates a new head node.
|
|
/// </summary>
|
|
private void UpsertLiveEnchantment(ActiveEnchantmentRecord record)
|
|
{
|
|
uint identity = record.Identity;
|
|
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
|
{
|
|
_activeById[identity] = record;
|
|
if (previous.Bucket == record.Bucket)
|
|
{
|
|
EnsureOrdered(identity, record.Bucket, insertAtHead: true);
|
|
return;
|
|
}
|
|
RemoveFromOrder(previous);
|
|
}
|
|
else
|
|
{
|
|
_activeById.Add(identity, record);
|
|
}
|
|
GetBucketOrder(record.Bucket).Insert(0, identity);
|
|
}
|
|
|
|
/// <summary>Bulk UnPack preserves the received head-to-tail list order.</summary>
|
|
private void UpsertManifestEnchantment(ActiveEnchantmentRecord record)
|
|
{
|
|
uint identity = record.Identity;
|
|
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
|
{
|
|
_activeById[identity] = record;
|
|
if (previous.Bucket == record.Bucket)
|
|
{
|
|
EnsureOrdered(identity, record.Bucket, insertAtHead: false);
|
|
return;
|
|
}
|
|
RemoveFromOrder(previous);
|
|
}
|
|
else
|
|
{
|
|
_activeById.Add(identity, record);
|
|
}
|
|
GetBucketOrder(record.Bucket).Add(identity);
|
|
}
|
|
|
|
private bool RemoveActiveEnchantment(
|
|
uint identity,
|
|
out ActiveEnchantmentRecord record)
|
|
{
|
|
if (!_activeById.Remove(identity, out record)) return false;
|
|
RemoveFromOrder(record);
|
|
return true;
|
|
}
|
|
|
|
private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead)
|
|
{
|
|
List<uint> order = GetBucketOrder(bucket);
|
|
if (order.Contains(identity)) return;
|
|
if (insertAtHead) order.Insert(0, identity);
|
|
else order.Add(identity);
|
|
}
|
|
|
|
private List<uint> GetBucketOrder(uint bucket)
|
|
{
|
|
if (_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order))
|
|
return order;
|
|
order = new List<uint>();
|
|
_enchantmentOrderByBucket.Add(bucket, order);
|
|
return order;
|
|
}
|
|
|
|
private void RemoveFromOrder(ActiveEnchantmentRecord record)
|
|
{
|
|
if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List<uint>? order))
|
|
return;
|
|
order.Remove(record.Identity);
|
|
if (order.Count == 0)
|
|
_enchantmentOrderByBucket.Remove(record.Bucket);
|
|
}
|
|
}
|
|
|
|
public readonly record struct LearnedSpellRecord(uint SpellId, float Power);
|
|
|
|
/// <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>. Both the complete
|
|
/// `PlayerDescription` snapshot and live MagicUpdateEnchantment events
|
|
/// populate the same record shape.
|
|
///
|
|
/// <para>
|
|
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
|
|
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
|
|
/// (<c>0x02</c>), vitae (<c>0x04</c>), or cooldown (<c>0x08</c>) per
|
|
/// the retail <c>EnchantmentMask</c> classification.
|
|
/// </para>
|
|
/// </summary>
|
|
public readonly record struct ActiveEnchantmentRecord(
|
|
uint SpellId,
|
|
uint LayerId,
|
|
double Duration,
|
|
uint CasterGuid,
|
|
uint? StatModType = null,
|
|
uint? StatModKey = null,
|
|
float? StatModValue = null,
|
|
uint Bucket = 0,
|
|
double StartTime = 0,
|
|
uint SpellCategory = 0,
|
|
uint PowerLevel = 0,
|
|
float DegradeModifier = 0,
|
|
float DegradeLimit = 0,
|
|
double LastTimeDegraded = 0,
|
|
uint? SpellSetId = null)
|
|
{
|
|
public uint Identity => MakeIdentity(SpellId, LayerId);
|
|
|
|
public static uint MakeIdentity(uint spellId, uint layerId) =>
|
|
(spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16);
|
|
}
|