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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -109,6 +109,106 @@ public sealed class EnchantmentMathTests
|
|||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches()
|
||||
{
|
||||
// Two multiplicative enchantments on MaxStamina (key=3): values
|
||||
// 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32.
|
||||
// Different families so neither dedups the other.
|
||||
var table = LoadTable(
|
||||
(10u, "Buff10", 100u),
|
||||
(11u, "Buff11", 200u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f),
|
||||
MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(1.32f, mod.Multiplier, precision: 4);
|
||||
Assert.Equal(0.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches()
|
||||
{
|
||||
var table = LoadTable(
|
||||
(20u, "Add1", 300u),
|
||||
(21u, "Add2", 301u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f),
|
||||
MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxMana);
|
||||
Assert.Equal(1.0f, mod.Multiplier);
|
||||
Assert.Equal(75.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_StatKeyMismatch_DoesNotContribute()
|
||||
{
|
||||
var table = LoadTable((30u, "Health buff", 500u));
|
||||
// Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3).
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs()
|
||||
{
|
||||
// Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10
|
||||
// additive from a Restoration buff. Family 0 means each is its
|
||||
// own bucket.
|
||||
var table = LoadTable(
|
||||
(40u, "Restoration", 0u),
|
||||
(41u, "Vitae", 0u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f),
|
||||
MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxHealth);
|
||||
// Vitae multiplier 0.85, additive 10.
|
||||
Assert.Equal(0.85f, mod.Multiplier, precision: 3);
|
||||
Assert.Equal(10.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_FamilyStacking_PicksHigherSpellId()
|
||||
{
|
||||
// Two spells in the same family — only the one with the higher
|
||||
// SpellId should contribute.
|
||||
var table = LoadTable(
|
||||
(10u, "Strength I", 1u), // Family=1
|
||||
(132u, "Strength VII", 1u)); // same family
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f),
|
||||
MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
// Only the higher-id buff (1.5) applies.
|
||||
Assert.Equal(1.5f, mod.Multiplier, precision: 3);
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u);
|
||||
|
||||
private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u);
|
||||
|
||||
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 8u);
|
||||
|
||||
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
|
||||
{
|
||||
// Build a synthetic CSV with just enough columns for SpellTable to
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue