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>
This commit is contained in:
Erik 2026-04-25 18:01:22 +02:00
parent b153bbe5ad
commit bb5003a849
7 changed files with 447 additions and 58 deletions

View file

@ -102,22 +102,34 @@ public static class EnchantmentMath
}
}
// Aggregate StatMod values from the deduplicated set.
// Note: ActiveEnchantmentRecord doesn't currently carry
// StatMod (type/key/val). Until ISSUES.md #12 lands the wire
// extension, this loop produces no aggregation effect — we
// keep the architecture in place so the swap-in is local.
// Aggregate StatMod values from the deduplicated set. Records
// with StatModKey == statKey contribute; bucket determines
// whether the value is multiplicative or additive.
// Bucket 1 (Multiplicative): multiplier *= ench.StatModValue
// Bucket 2 (Additive): additive += ench.StatModValue
// Bucket 8 (Vitae): multiplier *= ench.StatModValue (post-pass)
// Records without StatMod data (StatModKey == null) — e.g.
// those from older MagicUpdateEnchantment events that don't
// yet parse the full payload — contribute nothing.
float multiplier = 1.0f;
float additive = 0.0f;
float vitae = 1.0f;
foreach (var ench in stronger.Values)
{
// TODO ISSUES.md #12: filter by ench.StatModKey == statKey
// and apply ench.StatModType (multiplicative vs additive)
// with ench.StatModValue. For now, stat-key filtering is
// a no-op since the data isn't on the record yet.
_ = ench;
_ = statKey;
if (ench.StatModKey is not uint key || key != statKey) continue;
if (ench.StatModValue is not float val) continue;
switch (ench.Bucket)
{
case 1: multiplier *= val; break;
case 2: additive += val; break;
case 8: vitae *= val; break;
// Bucket 4 (Cooldown) doesn't affect vital max.
}
}
// Vitae is applied multiplicatively last per retail
// CEnchantmentRegistry::EnchantAttribute behaviour.
multiplier *= vitae;
return multiplier == 1.0f && additive == 0.0f
? VitalMod.Identity
: new VitalMod(multiplier, additive);

View file

@ -104,6 +104,19 @@ public sealed class Spellbook
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)
{
@ -129,13 +142,27 @@ public sealed class Spellbook
}
/// <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 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 SpellId,
uint LayerId,
float Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);