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
|
|
@ -86,6 +86,17 @@ public sealed class CharacterSheet
|
|||
public int ManaCurrent { get; init; }
|
||||
public int ManaMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unenchanted max Health/Stamina/Mana in that order.
|
||||
/// </summary>
|
||||
public int[] VitalBaseMaxValues { get; init; } = Array.Empty<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Isolated vitae contribution to max Health/Stamina/Mana, always
|
||||
/// non-positive and ordered like <see cref="VitalBaseMaxValues"/>.
|
||||
/// </summary>
|
||||
public int[] VitalVitaeModifiers { get; init; } = Array.Empty<int>();
|
||||
|
||||
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
|
||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
|
||||
|
||||
|
|
@ -202,7 +213,8 @@ public sealed record CharacterSkill(
|
|||
uint IconDid,
|
||||
CharacterSkillAdvancementClass AdvancementClass,
|
||||
int BaseLevel,
|
||||
// Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level —
|
||||
// BaseLevel is retail's pre-EnchantSkill value, including augmentation
|
||||
// terms. CurrentLevel is the EFFECTIVE (vitae + buff) level —
|
||||
// retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of
|
||||
// BaseLevel; this activates the existing CharacterStatController.
|
||||
// SkillValueColor buffed/debuffed row coloring.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ public sealed class CharacterSheetProvider
|
|||
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
|
||||
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
|
||||
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
|
||||
VitalBaseMaxValues =
|
||||
[
|
||||
VitalBaseMax(LocalPlayerState.VitalKind.Health),
|
||||
VitalBaseMax(LocalPlayerState.VitalKind.Stamina),
|
||||
VitalBaseMax(LocalPlayerState.VitalKind.Mana),
|
||||
],
|
||||
VitalVitaeModifiers =
|
||||
[
|
||||
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Health),
|
||||
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Stamina),
|
||||
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Mana),
|
||||
],
|
||||
|
||||
// Issue #267: the panel's main attribute values are EFFECTIVE
|
||||
// (post-buff) — retail CACQualities::EnchantAttribute. Base values
|
||||
|
|
@ -176,7 +188,7 @@ public sealed class CharacterSheetProvider
|
|||
AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||
AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||
},
|
||||
Skills = BuildLiveCharacterSkills(),
|
||||
Skills = BuildLiveCharacterSkills(props),
|
||||
BurdenCurrent = props.GetInt(5u),
|
||||
BurdenMax = props.GetInt(96u),
|
||||
EncumbranceAugmentations = props.GetInt(0xE6u),
|
||||
|
|
@ -344,7 +356,8 @@ public sealed class CharacterSheetProvider
|
|||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
|
||||
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills(
|
||||
PropertyBundle properties)
|
||||
{
|
||||
var result = new List<CharacterSkill>();
|
||||
var skillTable = SkillTable;
|
||||
|
|
@ -374,23 +387,26 @@ public sealed class CharacterSheetProvider
|
|||
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
|
||||
// isolates vitae's own contribution for the footer's separate
|
||||
// vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0).
|
||||
int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId)
|
||||
?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel));
|
||||
int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId);
|
||||
PlayerSkillMath.Value values =
|
||||
_localPlayer.GetSkillValue(snapshot.SkillId, properties)
|
||||
?? new PlayerSkillMath.Value(
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
|
||||
0);
|
||||
|
||||
result.Add(new CharacterSkill(
|
||||
snapshot.SkillId,
|
||||
name,
|
||||
icon,
|
||||
advancement,
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
|
||||
effectiveLevel,
|
||||
values.UnenchantedLevel,
|
||||
values.EffectiveLevel,
|
||||
IsUsableUntrained(snapshot.SkillId),
|
||||
trainedCost,
|
||||
specializedCost,
|
||||
raiseCost,
|
||||
raise10Cost,
|
||||
vitaeModifier));
|
||||
values.VitaeModifier));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -467,6 +483,11 @@ public sealed class CharacterSheetProvider
|
|||
private int VitalMax(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
|
||||
|
||||
private int VitalBaseMax(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.GetBaseMaxApprox(kind) is { } max
|
||||
? checked((int)Math.Min(int.MaxValue, max))
|
||||
: 0;
|
||||
|
||||
// ── Raise-request flow ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ public static class CharacterStatController
|
|||
/// <summary>Row highlight color — semi-translucent gold, matches retail
|
||||
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
|
||||
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
|
||||
private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f);
|
||||
// LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
|
||||
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
|
||||
private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f);
|
||||
private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f);
|
||||
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
|
||||
|
||||
// ── Row layout constants ─────────────────────────────────────────────────
|
||||
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
|
||||
|
|
@ -688,7 +692,8 @@ public static class CharacterStatController
|
|||
_ => 0,
|
||||
};
|
||||
return v.ToString();
|
||||
});
|
||||
},
|
||||
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
|
||||
|
||||
row.OnClick = () =>
|
||||
{
|
||||
|
|
@ -719,7 +724,8 @@ public static class CharacterStatController
|
|||
2 => $"{s.ManaCurrent}/{s.ManaMax}",
|
||||
_ => string.Empty,
|
||||
};
|
||||
});
|
||||
},
|
||||
valueColorProvider: () => VitalValueColor(data(), rowIndex));
|
||||
|
||||
row.OnClick = () =>
|
||||
{
|
||||
|
|
@ -872,10 +878,48 @@ public static class CharacterStatController
|
|||
return null;
|
||||
}
|
||||
|
||||
private static Vector4 SkillValueColor(CharacterSkill skill)
|
||||
=> skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen
|
||||
: skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f)
|
||||
internal static Vector4 SkillValueColor(CharacterSkill skill)
|
||||
{
|
||||
int withoutVitae = skill.CurrentLevel - skill.VitaeModifier;
|
||||
return withoutVitae > skill.BaseLevel ? RetailBuffGreen
|
||||
: withoutVitae < skill.BaseLevel ? RetailDebuffRed
|
||||
: Vector4.One;
|
||||
}
|
||||
|
||||
internal static Vector4 AttributeValueColor(
|
||||
CharacterSheet sheet,
|
||||
int rowIndex)
|
||||
{
|
||||
int delta = GetAttributeDelta(sheet, rowIndex);
|
||||
return delta > 0 ? RetailBuffGreen
|
||||
: delta < 0 ? RetailDebuffRed
|
||||
: Vector4.One;
|
||||
}
|
||||
|
||||
internal static Vector4 VitalValueColor(
|
||||
CharacterSheet sheet,
|
||||
int vitalIndex)
|
||||
{
|
||||
if ((uint)vitalIndex >= 3u
|
||||
|| vitalIndex >= sheet.VitalBaseMaxValues.Length
|
||||
|| vitalIndex >= sheet.VitalVitaeModifiers.Length)
|
||||
{
|
||||
return Vector4.One;
|
||||
}
|
||||
|
||||
int effective = vitalIndex switch
|
||||
{
|
||||
0 => sheet.HealthMax,
|
||||
1 => sheet.StaminaMax,
|
||||
2 => sheet.ManaMax,
|
||||
_ => 0,
|
||||
};
|
||||
int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex];
|
||||
int baseline = sheet.VitalBaseMaxValues[vitalIndex];
|
||||
return withoutVitae > baseline ? RetailBuffGreen
|
||||
: withoutVitae < baseline ? RetailDebuffRed
|
||||
: Vector4.One;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a row click: toggle (same row → deselect), else select new row.
|
||||
|
|
@ -1315,6 +1359,64 @@ public static class CharacterStatController
|
|||
return $"{name}: {value}{delta}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UiText.TextRun> BuildSelectedTitleRuns(
|
||||
UiText target,
|
||||
CharacterStatTab tab,
|
||||
Func<CharacterSheet> data,
|
||||
int[] attrSel,
|
||||
int[] skillSel)
|
||||
{
|
||||
Vector4 Color(int index) =>
|
||||
index >= 0 && index < target.FontColorPalette.Count
|
||||
? target.FontColorPalette[index]
|
||||
: index switch
|
||||
{
|
||||
1 => RetailBuffGreen,
|
||||
2 => RetailDebuffRed,
|
||||
3 => RetailVitaeBlue,
|
||||
_ => Vector4.One,
|
||||
};
|
||||
|
||||
if (tab == CharacterStatTab.Skills)
|
||||
{
|
||||
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||||
if (skill is null)
|
||||
return [new("Select a Skill to Improve", Body)];
|
||||
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||||
return [new(skill.Name, Color(0))];
|
||||
|
||||
var runs = new List<UiText.TextRun>
|
||||
{
|
||||
new($"{skill.Name}: {skill.CurrentLevel}", Color(0)),
|
||||
};
|
||||
if (skill.VitaeModifier < 0)
|
||||
runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3)));
|
||||
int buffDelta = GetSkillBuffOnlyDelta(skill);
|
||||
if (buffDelta != 0)
|
||||
runs.Add(new(
|
||||
FormatBuffDelta(buffDelta),
|
||||
Color(buffDelta > 0 ? 1 : 2)));
|
||||
return runs;
|
||||
}
|
||||
|
||||
if (attrSel[0] < 0)
|
||||
return [new("Select an Attribute to Improve", Body)];
|
||||
|
||||
CharacterSheet sheet = data();
|
||||
var attributeRuns = new List<UiText.TextRun>
|
||||
{
|
||||
new(
|
||||
$"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}",
|
||||
Color(0)),
|
||||
};
|
||||
int delta = GetAttributeDelta(sheet, attrSel[0]);
|
||||
if (delta != 0)
|
||||
attributeRuns.Add(new(
|
||||
FormatBuffDelta(delta),
|
||||
Color(delta > 0 ? 1 : 2)));
|
||||
return attributeRuns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a single attribute/vital row to <paramref name="list"/> as a
|
||||
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
|
||||
|
|
@ -1512,6 +1614,12 @@ public static class CharacterStatController
|
|||
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
|
||||
// RightAligned stays false (BuildText default for a Center element).
|
||||
titleEl.ClickThrough = true;
|
||||
titleEl.RunsProvider = () => BuildSelectedTitleRuns(
|
||||
titleEl,
|
||||
activeTab[0],
|
||||
data,
|
||||
attrSel,
|
||||
skillSel);
|
||||
titleEl.LinesProvider = () =>
|
||||
{
|
||||
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||
public readonly record struct Line(string Text, Vector4 Color);
|
||||
|
||||
/// <summary>
|
||||
/// One inline fragment in a retail <c>AppendTextWithFont</c> line.
|
||||
/// </summary>
|
||||
public readonly record struct TextRun(string Text, Vector4 Color);
|
||||
|
||||
/// <summary>A caret position: a line index into the cached line list plus a
|
||||
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
|
||||
public readonly record struct Pos(int Line, int Col);
|
||||
|
|
@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
|
||||
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional inline fragments for a static one-line element. When present
|
||||
/// this reproduces retail's per-append font-state colors while preserving
|
||||
/// the element's authored alignment as one composed line.
|
||||
/// </summary>
|
||||
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
|
||||
|
||||
/// <summary>Font for the transcript; falls back to the context default.</summary>
|
||||
public BitmapFont? Font { get; set; }
|
||||
|
||||
|
|
@ -381,6 +393,12 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
|
||||
private void DrawClippedText(UiRenderContext ctx)
|
||||
{
|
||||
if (OneLine && RunsProvider is { } runsProvider)
|
||||
{
|
||||
DrawSingleLineRuns(ctx, runsProvider());
|
||||
return;
|
||||
}
|
||||
|
||||
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
||||
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
|
||||
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
|
||||
|
|
@ -533,6 +551,54 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawSingleLineRuns(
|
||||
UiRenderContext ctx,
|
||||
IReadOnlyList<TextRun> runs)
|
||||
{
|
||||
if (runs.Count == 0) return;
|
||||
|
||||
UiDatFont? datFont = DatFont;
|
||||
BitmapFont? bitmapFont = datFont is null
|
||||
? Font ?? ctx.DefaultFont
|
||||
: null;
|
||||
if (datFont is null && bitmapFont is null) return;
|
||||
|
||||
float totalWidth = 0f;
|
||||
foreach (TextRun run in runs)
|
||||
{
|
||||
totalWidth += datFont is not null
|
||||
? datFont.MeasureWidth(run.Text)
|
||||
: bitmapFont!.MeasureWidth(run.Text);
|
||||
}
|
||||
|
||||
float x = Centered
|
||||
? Math.Max(Padding, (Width - totalWidth) * 0.5f)
|
||||
: RightAligned
|
||||
? Math.Max(Padding, Width - Padding - totalWidth)
|
||||
: Padding;
|
||||
float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight;
|
||||
float y = VOffset(
|
||||
Height,
|
||||
lineHeight,
|
||||
Padding,
|
||||
VerticalJustify);
|
||||
|
||||
foreach (TextRun run in runs)
|
||||
{
|
||||
if (run.Text.Length == 0) continue;
|
||||
if (datFont is not null)
|
||||
{
|
||||
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color);
|
||||
x += datFont.MeasureWidth(run.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
|
||||
x += bitmapFont!.MeasureWidth(run.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when any vertical portion of a line intersects a text viewport. Retail
|
||||
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
/// </summary>
|
||||
private int _runSkillBase = -1;
|
||||
private int _jumpSkillBase = -1;
|
||||
private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations;
|
||||
|
||||
public RuntimeCharacterState(SpellTable? spellTable = null)
|
||||
{
|
||||
|
|
@ -142,13 +143,14 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
&& MovementSkills.PlayerKillerStatus == -1
|
||||
&& MovementSkills.LastPkAttackTimestamp is null
|
||||
&& _runSkillBase == -1
|
||||
&& _jumpSkillBase == -1);
|
||||
&& _jumpSkillBase == -1
|
||||
&& _movementSkillAugmentations == default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
|
||||
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
|
||||
/// and pushes the vitae/enchantment-adjusted result into
|
||||
/// and pushes the augmentation/vitae/enchantment-adjusted result into
|
||||
/// <see cref="MovementSkills"/> — the SAME call shape
|
||||
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
|
||||
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
|
||||
|
|
@ -165,6 +167,22 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
RecomputeMovementSkills();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the player-quality augmentation terms consumed by retail
|
||||
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>. The object
|
||||
/// table remains authoritative for live PropertyInt updates; Runtime
|
||||
/// retains only this immutable derived snapshot.
|
||||
/// </summary>
|
||||
public void UpdateMovementSkillAugmentations(
|
||||
PlayerSkillMath.AugmentationBonuses augmentations)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_movementSkillAugmentations == augmentations)
|
||||
return;
|
||||
_movementSkillAugmentations = augmentations;
|
||||
RecomputeMovementSkills();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-derives the adjusted run/jump skill from the stored base plus the
|
||||
/// CURRENT spellbook state (vitae + skill enchantments) — matching
|
||||
|
|
@ -178,10 +196,10 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
private void RecomputeMovementSkills()
|
||||
{
|
||||
int run = _runSkillBase >= 0
|
||||
? ApplySkillEnchantments(_runSkillBase, RunSkillId)
|
||||
? CalculateMovementSkill(_runSkillBase, RunSkillId)
|
||||
: -1;
|
||||
int jump = _jumpSkillBase >= 0
|
||||
? ApplySkillEnchantments(_jumpSkillBase, JumpSkillId)
|
||||
? CalculateMovementSkill(_jumpSkillBase, JumpSkillId)
|
||||
: -1;
|
||||
// #266 apparatus (permanent, low-volume — fires only on skill-base or
|
||||
// enchantment changes, the [snap] class): the full stat-chain state at
|
||||
|
|
@ -196,14 +214,19 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
MovementSkills.Update(run, jump);
|
||||
}
|
||||
|
||||
private int ApplySkillEnchantments(int baseSkill, uint skillId)
|
||||
private int CalculateMovementSkill(int baseSkill, uint skillId)
|
||||
{
|
||||
EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId);
|
||||
float adjusted = baseSkill * mod.Multiplier + mod.Additive;
|
||||
// CEnchantmentRegistry::EnchantSkill pc 416240: floor to 0 below
|
||||
// 0.5, then truncate (retail _ftol2, a C-style cast).
|
||||
if (adjusted < 0.5f) adjusted = 0f;
|
||||
return (int)adjusted;
|
||||
float vitae = EnchantmentMath.GetVitaeMultiplier(
|
||||
Spellbook.ActiveEnchantments);
|
||||
uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u;
|
||||
return PlayerSkillMath.Calculate(
|
||||
baseSkill,
|
||||
skillId,
|
||||
advancementClass,
|
||||
_movementSkillAugmentations,
|
||||
mod,
|
||||
vitae).EffectiveLevel;
|
||||
}
|
||||
|
||||
private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills();
|
||||
|
|
@ -331,6 +354,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
Try(Options.ResetSession, ref failures);
|
||||
_runSkillBase = -1;
|
||||
_jumpSkillBase = -1;
|
||||
_movementSkillAugmentations = default;
|
||||
Try(MovementSkills.ResetSession, ref failures);
|
||||
if (failures is not null)
|
||||
{
|
||||
|
|
@ -355,6 +379,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
Try(Options.ResetSession, ref failures);
|
||||
_runSkillBase = -1;
|
||||
_jumpSkillBase = -1;
|
||||
_movementSkillAugmentations = default;
|
||||
Try(MovementSkills.ResetSession, ref failures);
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -200,27 +200,27 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectAdded += h,
|
||||
h => inventory.Objects.ObjectAdded -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectUpdated += h,
|
||||
h => inventory.Objects.ObjectUpdated -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectRemoved += h,
|
||||
h => inventory.Objects.ObjectRemoved -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
SubscribeToRecompute<ClientObjectMove>(
|
||||
h => inventory.Objects.ObjectMoved += h,
|
||||
h => inventory.Objects.ObjectMoved -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
SubscribeToRecompute<uint>(
|
||||
h => inventory.Objects.ContainerContentsReplaced += h,
|
||||
h => inventory.Objects.ContainerContentsReplaced -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
SubscribeParameterless(
|
||||
h => inventory.Objects.Cleared += h,
|
||||
h => inventory.Objects.Cleared -= h,
|
||||
() => { RecomputeBurden(inventory, character); RecomputePvpStatus(inventory, character); });
|
||||
() => RecomputePlayerQualities(inventory, character));
|
||||
Subscribe<LocalPlayerState.AttributeKind>(
|
||||
h => character.Character.LocalPlayer.AttributeChanged += h,
|
||||
h => character.Character.LocalPlayer.AttributeChanged -= h,
|
||||
|
|
@ -365,7 +365,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
/// </summary>
|
||||
private static void RecomputeBurden(
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character)
|
||||
LiveCharacterSessionBindings character,
|
||||
bool notify = true)
|
||||
{
|
||||
uint player = inventory.PlayerGuid();
|
||||
ClientObject? playerObject = inventory.Objects.Get(player);
|
||||
|
|
@ -381,6 +382,22 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
: inventory.Objects.SumCarriedBurden(player);
|
||||
float load = EncumbranceSystem.Load(capacity, burden);
|
||||
character.Character.MovementSkills.UpdateBurden(load);
|
||||
if (notify)
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
}
|
||||
|
||||
private static void RecomputePlayerQualities(
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character)
|
||||
{
|
||||
RecomputeBurden(inventory, character, notify: false);
|
||||
RecomputePvpStatus(inventory, character, notify: false);
|
||||
|
||||
uint player = inventory.PlayerGuid();
|
||||
PropertyBundle properties = inventory.Objects.Get(player)?.Properties
|
||||
?? character.Character.LocalPlayer.Properties;
|
||||
character.Character.UpdateMovementSkillAugmentations(
|
||||
PlayerSkillMath.AugmentationBonuses.FromProperties(properties));
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +414,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
/// </summary>
|
||||
private static void RecomputePvpStatus(
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character)
|
||||
LiveCharacterSessionBindings character,
|
||||
bool notify = true)
|
||||
{
|
||||
uint player = inventory.PlayerGuid();
|
||||
ClientObject? playerObject = inventory.Objects.Get(player);
|
||||
|
|
@ -415,7 +433,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
character.Character.MovementSkills.UpdatePlayerKillerStatus(
|
||||
pkStatus,
|
||||
lastPkAttackTimestamp);
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
if (notify)
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue