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:
commit
2493f24c63
12 changed files with 984 additions and 108 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue