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
|
|
@ -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