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