feat(player): port retail augmentation stat chain

This commit is contained in:
Erik 2026-07-31 08:08:23 +02:00
parent 0cb60d98a0
commit 461a1fb7b4
22 changed files with 953 additions and 138 deletions

View file

@ -47,12 +47,10 @@ namespace AcDream.Core.Player;
/// </para>
///
/// <para>
/// <b>Enchantment buffs</b> (multiplicative + additive) and the
/// 5-min-vital clamp are <b>not yet applied</b> — adding those
/// requires the <see cref="AcDream.Core.Spells.Spellbook"/>'s active
/// enchantment list. The unenchanted max is correct for clean
/// characters; buffed players will read percent slightly higher than
/// retail until enchantment integration lands.
/// <b>Enchantment buffs</b> (multiplicative + additive), vitae, and the
/// retail five-point minimum are applied through the attached
/// <see cref="AcDream.Core.Spells.Spellbook"/>. Base-value accessors retain
/// the unenchanted values needed by the character-panel comparison logic.
/// </para>
/// </summary>
public sealed class LocalPlayerState
@ -226,13 +224,37 @@ public sealed class LocalPlayerState
/// skill hasn't arrived yet.
/// </summary>
public int? GetEffectiveSkill(uint skillId)
=> GetSkillValue(skillId)?.EffectiveLevel;
/// <summary>
/// Full retail <c>CACQualities::InqSkill</c> projection, including the
/// augmentation terms on both sides of <c>EnchantSkill</c>. Callers with a
/// fresher player-object property bundle may supply it; otherwise the
/// PlayerDescription snapshot is used.
/// </summary>
public PlayerSkillMath.Value? GetSkillValue(
uint skillId,
PropertyBundle? properties = null)
{
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null;
uint baseValue = skill.Value.CurrentLevel;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetSkillMod(skillId);
return EnchantmentMath.EnchantSkill(mod, baseValue);
PlayerSkillMath.AugmentationBonuses augmentations =
PlayerSkillMath.AugmentationBonuses.FromProperties(
properties ?? _properties);
EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId)
?? EnchantmentMath.VitalMod.Identity;
float vitae = _spellbook is null
? 1f
: EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments);
return PlayerSkillMath.Calculate(
checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)),
skillId,
skill.Value.Status,
augmentations,
mod,
vitae);
}
/// <summary>
@ -242,14 +264,7 @@ public sealed class LocalPlayerState
/// is wired, no vitae is active, or the skill hasn't arrived yet.
/// </summary>
public int GetSkillVitaeModifier(uint skillId)
{
if (_spellbook is null) return 0;
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return 0;
return EnchantmentMath.SkillVitaeModifier(
_spellbook.ActiveEnchantments,
skill.Value.CurrentLevel);
}
=> GetSkillValue(skillId)?.VitaeModifier ?? 0;
/// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;
@ -290,11 +305,8 @@ public sealed class LocalPlayerState
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
var v = Get(kind);
if (v is null) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
uint unbuffed = baseMax + contrib;
uint? baseValue = GetBaseMaxApprox(kind);
if (baseValue is not uint unbuffed) return null;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base.
@ -311,6 +323,37 @@ public sealed class LocalPlayerState
return (uint)System.Math.Round(buffed);
}
/// <summary>
/// Unenchanted secondary-attribute maximum used by retail's
/// <c>Attribute2ndInfoRegion::Update @ 0x004F19E0</c> comparison.
/// </summary>
public uint? GetBaseMaxApprox(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind);
}
/// <summary>
/// Isolated vitae contribution to a secondary attribute, matching
/// <c>Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130</c>.
/// </summary>
public int GetVitalVitaeModifier(VitalKind kind)
{
if (_spellbook is null
|| GetBaseMaxApprox(kind) is not uint baseValue)
{
return 0;
}
return EnchantmentMath.SkillVitaeModifier(
EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments),
baseValue);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch
{
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,

View file

@ -0,0 +1,108 @@
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
/// <summary>
/// Retail <c>CACQualities::InqSkill @ 0x00592660</c> composition.
/// Keeps the augmentation/enchantment ordering in one presentation-independent
/// place so character UI and movement consume the same effective skill.
/// </summary>
public static class PlayerSkillMath
{
public readonly record struct AugmentationBonuses(
int AllSkills,
bool JackOfAllTrades,
int SkilledSpecialized,
bool SkilledMelee,
bool SkilledMissile,
bool SkilledMagic)
{
public static AugmentationBonuses FromProperties(PropertyBundle properties)
{
ArgumentNullException.ThrowIfNull(properties);
return new(
Positive(properties.GetInt((uint)PropertyInt.LumAugAllSkills)),
properties.GetInt((uint)PropertyInt.AugmentationJackOfAllTrades) > 0,
Positive(properties.GetInt((uint)PropertyInt.LumAugSkilledSpec)),
properties.GetInt((uint)PropertyInt.AugmentationSkilledMelee) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMissile) > 0,
properties.GetInt((uint)PropertyInt.AugmentationSkilledMagic) > 0);
}
public int BeforeEnchantments(uint skillId)
{
int category = skillId switch
{
// Retail switch at pc 0x005926F6. A positive category
// augmentation adds ten once; its stored rank is not multiplied.
0x29u or 0x2Cu or 0x2Du or 0x2Eu or 0x31u when SkilledMelee => 10,
0x2Fu when SkilledMissile => 10,
0x1Fu or 0x20u or 0x21u or 0x22u or 0x2Bu when SkilledMagic => 10,
_ => 0,
};
return SaturatingAdd(AllSkills, category);
}
public int AfterEnchantments(uint advancementClass)
{
int result = JackOfAllTrades ? 5 : 0;
if (advancementClass == 3u)
result = SaturatingAdd(result, SaturatingMultiply(SkilledSpecialized, 2));
return result;
}
}
public readonly record struct Value(
int UnenchantedLevel,
int EffectiveLevel,
int VitaeModifier);
/// <summary>
/// Compose one skill exactly in retail order:
/// intrinsic + LumAugAllSkills/category bonus; EnchantSkill; then
/// Jack of All Trades and the specialized luminance bonus.
/// </summary>
public static Value Calculate(
int intrinsicLevel,
uint skillId,
uint advancementClass,
AugmentationBonuses augmentations,
EnchantmentMath.VitalMod enchantment,
float vitaeMultiplier)
{
int intrinsic = Math.Max(0, intrinsicLevel);
int unenchanted = SaturatingAdd(
intrinsic,
augmentations.BeforeEnchantments(skillId));
int enchanted = EnchantmentMath.EnchantSkill(
enchantment,
(uint)unenchanted);
int effective = SaturatingAdd(
enchanted,
augmentations.AfterEnchantments(advancementClass));
int vitaeModifier = EnchantmentMath.SkillVitaeModifier(
vitaeMultiplier,
(uint)unenchanted);
return new Value(unenchanted, effective, vitaeModifier);
}
private static int Positive(int value) => value > 0 ? value : 0;
private static int SaturatingAdd(int left, int right)
{
long result = (long)left + right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
private static int SaturatingMultiply(int left, int right)
{
long result = (long)left * right;
return result > int.MaxValue
? int.MaxValue
: result < int.MinValue ? int.MinValue : (int)result;
}
}

View file

@ -28,22 +28,15 @@ namespace AcDream.Core.Spells;
/// <para>
/// <b>Vitae</b> (death penalty) is a singleton on
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
/// the buff lists. We don't yet wire it through.
/// the buff lists.
/// </para>
///
/// <para>
/// <b>Current implementation status:</b> the aggregator iterates
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
/// <see cref="SpellTable"/> family-stacking deduplication, but
/// **returns identity (1.0, 0.0) for stat modifiers** because our
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
/// <c>StatMod (type/key/val)</c> triad — that requires extending
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
/// payload (60-64 bytes per holtburger
/// <c>messages/magic/types.rs</c>) and storing it on the record.
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
/// the Vitals HUD percent gap closes.
/// <b>Current implementation status:</b> the aggregator consumes the same
/// complete <c>StatMod (type/key/value)</c> record shape from both the
/// PlayerDescription snapshot and live <c>MagicUpdateEnchantment</c>
/// (0x02C2), applies retail family stacking, then evaluates the selected
/// attribute, secondary attribute, or skill domain.
/// </para>
///
/// <para>
@ -167,9 +160,8 @@ public static class EnchantmentMath
// Bucket 2 (Additive): additive += ench.StatModValue
// Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass)
// Bucket 8 (Cooldown): skipped (doesn't affect vital max)
// Records without StatMod data (StatModKey == null) — e.g.
// those from older MagicUpdateEnchantment events that don't
// yet parse the full payload — contribute nothing.
// Records without StatMod data (StatModKey == null) are valid for
// non-stat enchantment classes and contribute nothing here.
float multiplier = 1.0f;
float additive = 0.0f;
float vitae = 1.0f;
@ -283,10 +275,17 @@ public static class EnchantmentMath
public static int SkillVitaeModifier(
IEnumerable<ActiveEnchantmentRecord> enchantments,
uint baseValue)
=> SkillVitaeModifier(GetVitaeMultiplier(enchantments), baseValue);
/// <summary>
/// Value overload for callers that already captured the registry's vitae
/// singleton. Keeping the truncation here prevents the movement and UI
/// paths from growing subtly different copies of retail's calculation.
/// </summary>
public static int SkillVitaeModifier(float vitaeMultiplier, uint baseValue)
{
float vitae = GetVitaeMultiplier(enchantments);
if (vitae == 1.0f) return 0;
return (int)(baseValue * vitae) - (int)baseValue;
if (vitaeMultiplier == 1.0f) return 0;
return (int)(baseValue * vitaeMultiplier) - (int)baseValue;
}
/// <summary>

View file

@ -267,11 +267,9 @@ public sealed class Spellbook
}
/// <summary>
/// Issue #7 / #12 — accept a fully-populated record from
/// <c>PlayerDescription</c>'s enchantment block (which carries
/// the StatMod triad + bucket). Used when the wire-format extension
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// Accept the canonical fully-populated enchantment record shared by
/// <c>PlayerDescription</c> and live <c>MagicUpdateEnchantment</c>
/// (0x02C2), including the StatMod triad and bucket classification.
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{