Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd (0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the three enchantment-composition functions the Character window's Attributes and Skills tabs depend on. Primary attributes never reference the vitae singleton in retail (only Attribute2nd/Skill do) — confirmed directly from the decompiled function bodies, not assumed. EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default to the prior behavior) so a numeric StatMod key collision across domains (e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the wrong computation. Spellbook.GetAttributeMod/GetSkillMod and LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/ GetSkillVitaeModifier wire the retail chain through to the panel. CharacterSheetProvider now reports the effective value as the main number and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this also activates the previously-dead SkillValueColor buffed/debuffed row coloring). CharacterStatController's footer-title parenthetical is cited from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) + SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two segments (vitae's own contribution, then the buff-only residual), while vitae-immune attributes show at most one; no parenthetical when the delta is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only raw property/attribute updates. Core goldens cover the user-reported 33% vitae example (303->203, "(-100)" exactly), buff+vitae composition, and the attribute vitae-immunity finding. Provider/controller tests cover the full row-click -> footer-title path and live refresh. Full solution suite passes with zero failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
13 KiB
C#
321 lines
13 KiB
C#
using System;
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Tests for <see cref="CharacterSheetProvider"/> — the extracted character-sheet
|
||
/// assembly + raise flow (formerly private methods inside GameWindow). Covers the
|
||
/// XP-curve math against a known synthetic ExperienceTable and the single-owner
|
||
/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire
|
||
/// CharacterChanged).
|
||
/// </summary>
|
||
public sealed class CharacterSheetProviderTests
|
||
{
|
||
private const uint PlayerGuid = 0x50000001u;
|
||
|
||
/// <summary>Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250.</summary>
|
||
private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new()
|
||
{
|
||
Levels = new ulong[] { 0, 100, 250, 450 },
|
||
Attributes = new uint[] { 0, 10, 30, 60, 100 },
|
||
Vitals = new uint[] { 0, 4, 12, 24 },
|
||
TrainedSkills = new uint[] { 0, 5, 15, 30 },
|
||
SpecializedSkills = new uint[] { 0, 8, 24, 48 },
|
||
};
|
||
|
||
private sealed class Harness
|
||
{
|
||
public ClientObjectTable Table { get; } = new();
|
||
public LocalPlayerState Player { get; } = new();
|
||
public CharacterSheetProvider Provider { get; }
|
||
public (uint statId, ulong cost)? SentAttribute;
|
||
public (uint skillId, uint credits)? SentTrain;
|
||
public bool CanSend = true;
|
||
|
||
public Harness()
|
||
{
|
||
Provider = new CharacterSheetProvider(
|
||
Table, Player,
|
||
playerGuid: () => PlayerGuid,
|
||
activeToonName: () => "default",
|
||
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 },
|
||
canSendRaise: () => CanSend,
|
||
sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost),
|
||
sendRaiseVital: (_, _) => { },
|
||
sendRaiseSkill: (_, _) => { },
|
||
sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits))
|
||
{
|
||
ExperienceTable = MakeXpTable(),
|
||
};
|
||
}
|
||
|
||
/// <summary>Put the player's ClientObject in the table with live sheet properties.</summary>
|
||
public ClientObject AddPlayerObject(long unassignedXp = 1000L)
|
||
{
|
||
var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||
player.Properties.Ints[0x19u] = 1; // level
|
||
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
|
||
player.Properties.Int64s[2u] = unassignedXp;
|
||
player.Properties.Ints[0x18u] = 4; // available skill credits
|
||
Table.AddOrUpdate(player);
|
||
return player;
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void BuildSheet_NoLiveData_UsesFallbackSheet()
|
||
{
|
||
var h = new Harness();
|
||
|
||
var sheet = h.Provider.BuildSheet();
|
||
|
||
Assert.Equal(-1, sheet.Level); // fallback marker
|
||
Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player"
|
||
}
|
||
|
||
[Fact]
|
||
public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts()
|
||
{
|
||
var h = new Harness();
|
||
h.AddPlayerObject(unassignedXp: 777L);
|
||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength
|
||
|
||
var sheet = h.Provider.BuildSheet();
|
||
|
||
Assert.Equal("Testy", sheet.Name); // live object name beats "Player"
|
||
Assert.Equal(1, sheet.Level);
|
||
Assert.Equal(150L, sheet.TotalXp);
|
||
Assert.Equal(777L, sheet.UnassignedXp);
|
||
// Level band 100..250, at 150: 100 XP to next, 1/3 through the band.
|
||
Assert.Equal(100L, sheet.XpToNextLevel);
|
||
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
|
||
Assert.Equal(11, sheet.Strength); // ranks + start
|
||
Assert.Equal(4, sheet.SkillCredits);
|
||
// Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10.
|
||
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
|
||
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter()
|
||
{
|
||
var h = new Harness();
|
||
h.AddPlayerObject(unassignedXp: 0L);
|
||
|
||
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 1u, 200L));
|
||
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 2u, 75L));
|
||
|
||
var sheet = h.Provider.BuildSheet();
|
||
|
||
Assert.Equal(200L, sheet.TotalXp);
|
||
Assert.Equal(75L, sheet.UnassignedXp);
|
||
Assert.Equal(50L, sheet.XpToNextLevel);
|
||
Assert.Equal(2f / 3f, sheet.XpFraction, precision: 4);
|
||
}
|
||
|
||
[Fact]
|
||
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
|
||
{
|
||
var h = new Harness();
|
||
h.AddPlayerObject();
|
||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u,
|
||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained
|
||
h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u,
|
||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded
|
||
|
||
var sheet = h.Provider.BuildSheet();
|
||
|
||
var skill = Assert.Single(sheet.Skills);
|
||
Assert.Equal(6u, skill.Id);
|
||
Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name
|
||
Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass);
|
||
// TrainedSkills curve: x1 = 15 − 5; x10 clamps at index 3: 30 − 5.
|
||
Assert.Equal(10L, skill.RaiseCost);
|
||
Assert.Equal(25L, skill.Raise10Cost);
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents()
|
||
{
|
||
var h = new Harness();
|
||
h.AddPlayerObject(unassignedXp: 1000L);
|
||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||
int tableUpdates = 0;
|
||
h.Table.ObjectUpdated += _ => tableUpdates++;
|
||
|
||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||
|
||
Assert.Equal((1u, 20ul), h.SentAttribute);
|
||
var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength);
|
||
Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply
|
||
Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited
|
||
Assert.True(tableUpdates >= 1); // via the eventful API
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse()
|
||
{
|
||
var h = new Harness();
|
||
h.AddPlayerObject(unassignedXp: 1000L);
|
||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||
h.CanSend = false;
|
||
|
||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||
|
||
Assert.Null(h.SentAttribute);
|
||
Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks);
|
||
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u));
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleRaiseRequest_TrainSkill_DebitsRetailSkillCreditProperty()
|
||
{
|
||
var h = new Harness();
|
||
var player = h.AddPlayerObject();
|
||
player.Properties.Ints[0x18u] = 4;
|
||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
|
||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
|
||
|
||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
|
||
|
||
Assert.Equal((6u, 4u), h.SentTrain);
|
||
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
||
Assert.Equal(0, player.Properties.GetInt(0x18u)); // credits debited
|
||
}
|
||
|
||
[Fact]
|
||
public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
|
||
{
|
||
var h = new Harness(); // note: nothing added to the table
|
||
var props = new PropertyBundle();
|
||
props.Int64s[2u] = 500L;
|
||
h.Player.OnProperties(props);
|
||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||
int changed = 0;
|
||
h.Player.CharacterChanged += () => changed++;
|
||
|
||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
|
||
|
||
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);
|
||
}
|
||
}
|