merge: #267 vitae character-panel display (attributes vitae-immune per retail; skill dual parentheticals)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:14:48 +02:00
commit 2493f24c63
12 changed files with 984 additions and 108 deletions

View file

@ -3,6 +3,7 @@ using System.Linq;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -208,4 +209,113 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
Assert.True(changed >= 1); // and CharacterChanged fired
}
// ── Issue #267 — vitae/buff-aware skill + attribute values ───────────────
private static SpellMetadata TestSpell(uint spellId) => new(
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
private sealed class VitaeHarness
{
public ClientObjectTable Table { get; } = new();
public Spellbook Book { get; }
public LocalPlayerState Player { get; }
public CharacterSheetProvider Provider { get; }
public VitaeHarness()
{
Book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
Player = new LocalPlayerState(Book);
Provider = new CharacterSheetProvider(
Table, Player,
playerGuid: () => 0u,
activeToonName: () => "default",
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 });
}
}
[Fact]
public void BuildSheet_AttributeBuff_ShowsEffectiveValueAndBasePair()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
var sheet = h.Provider.BuildSheet();
Assert.Equal(220, sheet.Strength); // effective: 200 * 1.1
Assert.Equal(200, sheet.AttributeBaseValues[0]); // base unaffected
}
[Fact]
public void BuildSheet_AttributeUnderVitae_AttributesAreVitaeImmune()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
Assert.Equal(200, sheet.Strength); // unaffected by vitae
Assert.Equal(200, sheet.AttributeBaseValues[0]);
}
[Fact]
public void BuildSheet_SkillUnderVitae_ShowsEffectiveLevelAndVitaeModifier()
{
var h = new VitaeHarness();
h.Player.OnSkillUpdate(skillId: 6u, ranks: 300u, status: 2u, xp: 0u,
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 303
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
var skill = Assert.Single(sheet.Skills);
Assert.Equal(303, skill.BaseLevel);
Assert.Equal(203, skill.CurrentLevel); // 303 * 0.67, truncated
Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example
}
[Fact]
public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
int changed = 0;
using IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
Assert.Equal(200, h.Provider.BuildSheet().Strength); // no buff yet
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
Assert.True(changed >= 1); // live-refresh notice fired
Assert.Equal(220, h.Provider.BuildSheet().Strength); // and the rebuilt sheet reflects it
}
[Fact]
public void SubscribeChanged_Dispose_UnsubscribesFromEnchantmentsChanged()
{
var h = new VitaeHarness();
int changed = 0;
IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
subscription.Dispose();
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u));
Assert.Equal(0, changed);
}
}

View file

@ -968,7 +968,11 @@ public class CharacterStatControllerTests
var rows = SkillRows(list);
rows[1].OnClick!();
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
// SampleData's War Magic row is intentionally Base=280/Current=285 (an
// illustrative buffed-skill sample — see SampleData.cs comment); issue
// #267 wires the retail "(+5)" buff-delta parenthetical onto that gap,
// which was previously computed but never surfaced in the title text.
Assert.Equal("War Magic: 285 (+5)", title.LinesProvider()[0].Text);
Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text);
Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text);
Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text);
@ -1269,6 +1273,178 @@ public class CharacterStatControllerTests
public void GetRowName_NegativeIndex_ReturnsEmpty()
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
// ── Issue #267 — vitae/buff delta parenthetical ──────────────────────────
// Retail format cited from gmAttributeUI::DisplaySelectionFooter_Attribute
// (0x0049d280, " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained
// (0x0049b860, vitae segment " (%d)" + buff segment " (%s%d)").
[Fact]
public void GetAttributeDelta_ComputesEffectiveMinusBase()
{
var sheet = new CharacterSheet { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
Assert.Equal(20, CharacterStatController.GetAttributeDelta(sheet, 0));
}
[Fact]
public void GetAttributeDelta_VitalRowIndex_ReturnsZero()
{
// Vitals (rows 6-8) use a "cur/max" footer format in retail
// (DisplaySelectionFooter_Vital), not this delta pattern.
var sheet = new CharacterSheet { AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 6));
}
[Fact]
public void GetAttributeDelta_EmptyBaseValues_ReturnsZero()
{
// SampleData / older sheets that don't populate AttributeBaseValues
// must not throw or fabricate a delta.
var sheet = new CharacterSheet { Strength = 220 };
Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 0));
}
[Fact]
public void GetSkillBuffOnlyDelta_IsolatesBuffFromVitae()
{
// CurrentLevel=251 already includes vitae (-99); the buff-only
// residual is (251 - (-99)) - 300 = 50.
var skill = new CharacterSkill(1u, "S", 0u, CharacterSkillAdvancementClass.Trained,
BaseLevel: 300, CurrentLevel: 251, UsableUntrained: true,
TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0, VitaeModifier: -99);
Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill));
}
[Fact]
public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength = index 0
Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_AttributeWithDebuff_FooterTitleShowsNegativeDelta()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[1].OnClick!(); // Endurance = index 1
Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_AttributeZeroDelta_NoParenthetical()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!();
Assert.Equal("Strength: 200", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_VitaeOnly_FooterTitleShowsVitaeParenthetical()
{
// User-reported oracle (ISSUES.md #267): a base-303 skill under 33%
// vitae shows the current (reduced) level with "(-100)".
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 203, baseLevel: 303, vitaeModifier: -100);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 203 (-100)", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_VitaePlusBuff_FooterTitleShowsBothParentheticals()
{
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
// CurrentLevel=251, base=300, vitae modifier=-99 (300*0.67-300).
// Buff-only delta = (251 - (-99)) - 300 = 50.
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 251, baseLevel: 300, vitaeModifier: -99);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_ZeroDelta_NoParentheticals()
{
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 300, baseLevel: 300, vitaeModifier: 0);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 300", title.LinesProvider()[0].Text);
}
private static CharacterSheet VitaeSkillSheet(int currentLevel, int baseLevel, int vitaeModifier) => new()
{
SkillCredits = 0,
UnassignedXp = 1_000_000,
Skills =
[
new CharacterSkill(
200u,
"Test Skill",
0x06000001u,
CharacterSkillAdvancementClass.Trained,
BaseLevel: baseLevel,
CurrentLevel: currentLevel,
UsableUntrained: true,
TrainedCost: 0,
SpecializedCost: 0,
RaiseCost: 100,
Raise10Cost: 1000,
VitaeModifier: vitaeModifier),
],
};
// ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]