fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s

This commit is contained in:
Erik 2026-08-20 13:26:35 +02:00
parent 4d84456c21
commit 1bd2b30291
36 changed files with 1462 additions and 86 deletions

View file

@ -1,5 +1,6 @@
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Player;
@ -170,6 +171,123 @@ public sealed class LocalPlayerStateTests
Assert.Equal(1f, s.StaminaPercent!.Value);
}
[Fact]
public void GetMaxApprox_PrimaryAttributeModifierWithCollidingKeyDoesNotAffectHealth()
{
// Regression: PropertyAttribute.Strength and
// PropertyAttribute2nd.MaxHealth both use numeric key 1. The HUD's
// GetVitalMod path previously omitted retail's SecondAtt domain
// filter, so a near-identity Strength modifier reproduced the live
// report exactly: 99999 current / 99998 max.
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u,
StatModValue: 0.99999f,
Bucket: 1u));
var s = new LocalPlayerState(book);
s.OnVitalUpdate(
vitalId: 7u,
ranks: 99_999u,
start: 0u,
xp: 0u,
current: 99_999u);
Assert.Equal(99_999u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(99_999u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(1f, s.HealthPercent);
}
[Fact]
public void GetMaxApprox_SecondaryAttributeModifierTruncatesLikeRetail()
{
// CEnchantmentRegistry::EnchantAttribute2nd ends in _ftol2. A
// fractional secondary-attribute result is truncated, not rounded.
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 0.75f,
Bucket: 2u));
var s = new LocalPlayerState(book);
s.OnVitalUpdate(
vitalId: 7u,
ranks: 100u,
start: 0u,
xp: 0u,
current: 100u);
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
}
[Fact]
public void GetMaxApprox_PrimaryAttributeBuffsFeedVitalFormula_ExactLiveRegression()
{
// Live screenshot regression: the server-authoritative currents were
// 38/75/25 while the HUD showed the raw-formula maxima 30/60/10.
// Retail InqAttribute2nd evaluates Endurance/Self through enchanted
// InqAttribute before applying the separate secondary-attribute mod.
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 2u, // Endurance
StatModValue: 15f,
Bucket: 2u));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u,
LayerId: 2u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 6u, // Self
StatModValue: 15f,
Bucket: 2u));
var s = new LocalPlayerState(book);
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 30u, xp: 0u);
s.OnAttributeUpdate(atType: 6u, ranks: 0u, start: 10u, xp: 0u);
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 15u, xp: 0u, current: 38u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 30u, xp: 0u, current: 75u);
s.OnVitalUpdate(vitalId: 9u, ranks: 0u, start: 0u, xp: 0u, current: 25u);
Assert.Equal(30u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(60u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(10u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(38u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(75u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(25u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(1f, s.HealthPercent);
Assert.Equal(1f, s.StaminaPercent);
Assert.Equal(1f, s.ManaPercent);
}
[Fact]
public void GetMaxApprox_HealthRoundsHalfEnduranceAndIncludesGearMaxHealth()
{
// SkillFormula::Calculate @ 0x00591960 rounds 45/2 to 23, and
// InqAttribute2nd @ 0x00592020 adds property 379 before enchantment.
var s = new LocalPlayerState();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.GearMaxHealth] = 7;
s.OnProperties(properties);
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 45u, xp: 0u);
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 10u, xp: 0u, current: 40u);
Assert.Equal(40u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(40u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
}
[Fact]
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
{

View file

@ -103,6 +103,7 @@ public sealed class SpellbookTests
LayerId: 7u,
Duration: 300f,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 1.5f,
Bucket: 1u));
@ -134,6 +135,7 @@ public sealed class SpellbookTests
LayerId: 7u,
Duration: 300f,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 1.25f,
Bucket: 1u));