feat(player): port retail augmentation stat chain
This commit is contained in:
parent
0cb60d98a0
commit
461a1fb7b4
22 changed files with 953 additions and 138 deletions
|
|
@ -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,
|
||||
|
|
|
|||
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal file
108
src/AcDream.Core/Player/PlayerSkillMath.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue