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:
parent
b153bbe5ad
commit
bb5003a849
7 changed files with 447 additions and 58 deletions
|
|
@ -207,6 +207,108 @@ public sealed class PlayerDescriptionParserTests
|
|||
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket()
|
||||
{
|
||||
// ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus
|
||||
// SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty
|
||||
// attribute block + empty spell table + 1 multiplicative
|
||||
// enchantment + 1 additive enchantment. Verifies end-to-end
|
||||
// that the enchantment record schema lands intact.
|
||||
var sb = new MemoryStream();
|
||||
using var writer = new BinaryWriter(sb);
|
||||
writer.Write(0u); // propertyFlags
|
||||
writer.Write(0x52u); // weenieType
|
||||
// vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301
|
||||
writer.Write(0x301u);
|
||||
writer.Write(1u); // has_health
|
||||
writer.Write(0u); // attribute_flags = 0 -> no entries
|
||||
|
||||
// Spell table: empty (count=0).
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)0);
|
||||
|
||||
// EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03
|
||||
writer.Write(0x03u);
|
||||
// Multiplicative list: 1 entry
|
||||
writer.Write(1u);
|
||||
WriteEnchantment(writer,
|
||||
spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0,
|
||||
powerLevel: 999, startTime: 12.5, duration: 1800.0,
|
||||
casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f,
|
||||
lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */,
|
||||
statModValue: 1.5f);
|
||||
// Additive list: 1 entry
|
||||
writer.Write(1u);
|
||||
WriteEnchantment(writer,
|
||||
spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0,
|
||||
powerLevel: 100, startTime: 13.0, duration: 1500.0,
|
||||
casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f,
|
||||
lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */,
|
||||
statModValue: 25.0f);
|
||||
|
||||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(2, parsed!.Value.Enchantments.Count);
|
||||
|
||||
var mult = parsed.Value.Enchantments[0];
|
||||
Assert.Equal((ushort)1234, mult.SpellId);
|
||||
Assert.Equal((ushort)5, mult.Layer);
|
||||
Assert.Equal(3u, mult.StatModKey);
|
||||
Assert.Equal(1.5f, mult.StatModValue);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket);
|
||||
|
||||
var add = parsed.Value.Enchantments[1];
|
||||
Assert.Equal((ushort)5678, add.SpellId);
|
||||
Assert.Equal(5u, add.StatModKey);
|
||||
Assert.Equal(25.0f, add.StatModValue);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_VitaeSingleton_AppearsInEnchantments()
|
||||
{
|
||||
// EnchantmentMask = VITAE only (0x08). Single Enchantment, no
|
||||
// count prefix.
|
||||
var sb = new MemoryStream();
|
||||
using var writer = new BinaryWriter(sb);
|
||||
writer.Write(0u); // propertyFlags
|
||||
writer.Write(0x52u); // weenieType
|
||||
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
|
||||
writer.Write(1u); // has_health
|
||||
writer.Write(0u); // empty attribute_flags
|
||||
|
||||
writer.Write(0x08u); // EnchantmentMask = VITAE
|
||||
WriteEnchantment(writer,
|
||||
spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0,
|
||||
powerLevel: 0, startTime: 0.0, duration: -1.0,
|
||||
casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f,
|
||||
lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */,
|
||||
statModValue: 0.95f);
|
||||
|
||||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Single(parsed!.Value.Enchantments);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket);
|
||||
Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue);
|
||||
}
|
||||
|
||||
private static void WriteEnchantment(BinaryWriter w,
|
||||
ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId,
|
||||
uint powerLevel, double startTime, double duration, uint casterGuid,
|
||||
float degradeMod, float degradeLimit, double lastDegraded,
|
||||
uint statModType, uint statModKey, float statModValue)
|
||||
{
|
||||
w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId);
|
||||
w.Write(powerLevel); w.Write(startTime); w.Write(duration);
|
||||
w.Write(casterGuid);
|
||||
w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded);
|
||||
w.Write(statModType); w.Write(statModKey); w.Write(statModValue);
|
||||
// Skip optional spell_set_id (only present if hasSpellSetId != 0).
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_SpellTable_PopulatesSpellsDictionary()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue