Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
423 lines
14 KiB
C#
423 lines
14 KiB
C#
using AcDream.Core.Items;
|
|
using AcDream.Core.Player;
|
|
|
|
namespace AcDream.Core.Tests.Player;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="LocalPlayerState"/> — per-vital + per-attribute
|
|
/// cache populated from <c>PlayerDescription</c>'s attribute block (ids
|
|
/// 1..=6 primary attrs, 7..=9 vitals) and from
|
|
/// <c>PrivateUpdateVital(Current)</c> deltas (ids 1..=6 in their
|
|
/// alternate role as wire-opcode <c>Vital</c> ids).
|
|
///
|
|
/// <para>
|
|
/// Max formula tested here: <c>vital.(ranks+start) + attribute_contribution</c>
|
|
/// with retail coefficients (Endurance/2 for Health, Endurance for Stamina,
|
|
/// Self for Mana). See class doc.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LocalPlayerStateTests
|
|
{
|
|
[Fact]
|
|
public void Defaults_AllVitalsNull_PercentsNull()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Mana));
|
|
Assert.Null(s.HealthPercent);
|
|
Assert.Null(s.StaminaPercent);
|
|
Assert.Null(s.ManaPercent);
|
|
}
|
|
|
|
[Theory]
|
|
// Wire opcode IDs (PrivateUpdateVital + PrivateUpdateVitalCurrent),
|
|
// ACE Vital enum: Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6.
|
|
[InlineData(1u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(2u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(3u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(4u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(5u, LocalPlayerState.VitalKind.Mana)]
|
|
[InlineData(6u, LocalPlayerState.VitalKind.Mana)]
|
|
// PlayerDescription attribute-block IDs.
|
|
[InlineData(7u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(8u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(9u, LocalPlayerState.VitalKind.Mana)]
|
|
public void VitalIdToKind_MapsBothIdSystems_ToSameKind(uint vitalId, LocalPlayerState.VitalKind expected)
|
|
{
|
|
Assert.Equal(expected, LocalPlayerState.VitalIdToKind(vitalId));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(10u)]
|
|
[InlineData(99u)]
|
|
public void VitalIdToKind_ReturnsNull_ForUnknownId(uint vitalId)
|
|
{
|
|
Assert.Null(LocalPlayerState.VitalIdToKind(vitalId));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1u, LocalPlayerState.AttributeKind.Strength)]
|
|
[InlineData(2u, LocalPlayerState.AttributeKind.Endurance)]
|
|
[InlineData(3u, LocalPlayerState.AttributeKind.Quickness)]
|
|
[InlineData(4u, LocalPlayerState.AttributeKind.Coordination)]
|
|
[InlineData(5u, LocalPlayerState.AttributeKind.Focus)]
|
|
[InlineData(6u, LocalPlayerState.AttributeKind.Self)]
|
|
public void AttributeIdToKind_MapsPrimaryAttrIds(uint atType, LocalPlayerState.AttributeKind expected)
|
|
{
|
|
Assert.Equal(expected, LocalPlayerState.AttributeIdToKind(atType));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(7u)] // Vitals are not primary attrs in this lookup.
|
|
[InlineData(99u)]
|
|
public void AttributeIdToKind_ReturnsNull_ForNonPrimaryIds(uint atType)
|
|
{
|
|
Assert.Null(LocalPlayerState.AttributeIdToKind(atType));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalUpdate_PopulatesSnapshot_FromFullMessage()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 4u, ranks: 100u, start: 120u, xp: 50000u, current: 180u);
|
|
|
|
var stam = s.Get(LocalPlayerState.VitalKind.Stamina);
|
|
Assert.NotNull(stam);
|
|
Assert.Equal(100u, stam!.Value.Ranks);
|
|
Assert.Equal(120u, stam.Value.Start);
|
|
Assert.Equal(50000u, stam.Value.Xp);
|
|
Assert.Equal(180u, stam.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnAttributeUpdate_PopulatesSnapshot()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 100u, xp: 12345u);
|
|
|
|
var endurance = s.GetAttribute(LocalPlayerState.AttributeKind.Endurance);
|
|
Assert.NotNull(endurance);
|
|
Assert.Equal(50u, endurance!.Value.Ranks);
|
|
Assert.Equal(100u, endurance.Value.Start);
|
|
Assert.Equal(12345u, endurance.Value.Xp);
|
|
// Current = ranks + start.
|
|
Assert.Equal(150u, endurance.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void HealthPercent_UsesEnduranceContribution_DividedByTwo()
|
|
{
|
|
// Endurance.current = ranks(50) + start(150) = 200. Contribution = 100.
|
|
// Health vital: ranks=0 start=0 cur=80. MaxApprox = 0 + 100 = 100. Percent = 0.8.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance
|
|
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 0u, xp: 0u, current: 80u); // Health (PD id)
|
|
|
|
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
|
Assert.Equal(0.8f, s.HealthPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaminaPercent_UsesEnduranceContribution_FullValue()
|
|
{
|
|
// Endurance.current = 200, Health/2=100. Stamina takes full Endurance=200.
|
|
// Stamina vital: ranks=0 start=0 cur=150. MaxApprox = 0 + 200 = 200. Percent = 0.75.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
|
|
|
|
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Equal(0.75f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ManaPercent_UsesSelfContribution()
|
|
{
|
|
// Self.current = 100. Mana vital: ranks=20 start=80 cur=100. MaxApprox = 100 + 100 = 200.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 6u, ranks: 50u, start: 50u, xp: 0u); // Self
|
|
s.OnVitalUpdate(vitalId: 9u, ranks: 20u, start: 80u, xp: 0u, current: 100u); // Mana
|
|
|
|
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
|
|
Assert.Equal(0.5f, s.ManaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Percent_ZeroWhenAttributeAndVitalBothZero()
|
|
{
|
|
// Without any attribute or vital ranks, MaxApprox=0 → percent null
|
|
// (no /0). Vital received but no useful information.
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 0u);
|
|
|
|
Assert.Null(s.StaminaPercent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Percent_ClampsToOne_WhenCurrentExceedsMax()
|
|
{
|
|
// Endurance contribution = 100/1 = 100. Stamina ranks+start = 0.
|
|
// MaxApprox = 100. Current = 150 → ratio 1.5 → clamps to 1.0.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 50u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
|
|
|
|
Assert.Equal(1f, s.StaminaPercent!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 50000u, current: 180u);
|
|
s.OnVitalCurrent(vitalId: 8u, current: 90u);
|
|
|
|
var stam = s.Get(LocalPlayerState.VitalKind.Stamina)!.Value;
|
|
Assert.Equal(0u, stam.Ranks);
|
|
Assert.Equal(0u, stam.Start);
|
|
Assert.Equal(50000u, stam.Xp);
|
|
Assert.Equal(90u, stam.Current);
|
|
// Endurance.current = 200; MaxApprox = 200; percent = 90/200 = 0.45.
|
|
Assert.Equal(0.45f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalCurrent_NoOp_WhenNoFullUpdateYet()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalCurrent(vitalId: 4u, current: 90u);
|
|
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Null(s.StaminaPercent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Changed_FiresOnFullVitalUpdate_WithCorrectKind()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var seen = new List<LocalPlayerState.VitalKind>();
|
|
s.Changed += k => seen.Add(k);
|
|
|
|
s.OnVitalUpdate(vitalId: 2u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
s.OnVitalUpdate(vitalId: 4u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
s.OnVitalUpdate(vitalId: 6u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
LocalPlayerState.VitalKind.Health,
|
|
LocalPlayerState.VitalKind.Stamina,
|
|
LocalPlayerState.VitalKind.Mana,
|
|
}, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeChanged_FiresOnPrimaryAttrUpdate()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var seen = new List<LocalPlayerState.AttributeKind>();
|
|
s.AttributeChanged += k => seen.Add(k);
|
|
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 1u, start: 1u, xp: 0u); // Endurance
|
|
s.OnAttributeUpdate(atType: 6u, ranks: 1u, start: 1u, xp: 0u); // Self
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
LocalPlayerState.AttributeKind.Endurance,
|
|
LocalPlayerState.AttributeKind.Self,
|
|
}, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnAttributeUpdate_DoesNotAffectVitals_DirectlyButRefreshesPercent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 100u);
|
|
|
|
// Pre-attribute: percent null because MaxApprox = 0.
|
|
Assert.Null(s.StaminaPercent);
|
|
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance.current = 200
|
|
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
|
|
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnProperties_ClonesBundleAndExposesInt64()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Ints[0x19u] = 126;
|
|
props.Int64s[1u] = 1_234_567_890L;
|
|
|
|
s.OnProperties(props);
|
|
props.Ints[0x19u] = 1;
|
|
props.Int64s[1u] = 2L;
|
|
|
|
Assert.Equal(126, s.Properties.GetInt(0x19u));
|
|
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
s.OnSkillUpdate(
|
|
skillId: 24u,
|
|
ranks: 12u,
|
|
status: 2u,
|
|
xp: 3456u,
|
|
init: 30u,
|
|
resistance: 0u,
|
|
lastUsed: 1.5,
|
|
formulaBonus: 80u);
|
|
|
|
var run = s.GetSkill(24u);
|
|
Assert.NotNull(run);
|
|
Assert.Equal(122u, run!.Value.CurrentLevel);
|
|
Assert.Equal(2u, run.Value.Status);
|
|
Assert.Equal(3456u, run.Value.Xp);
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
s.OnSkillUpdate(
|
|
skillId: 21u,
|
|
ranks: 0u,
|
|
status: 1u,
|
|
xp: 0u,
|
|
init: 5u,
|
|
resistance: 0u,
|
|
lastUsed: 0,
|
|
formulaBonus: 10u);
|
|
changed = 0;
|
|
|
|
Assert.True(s.ApplySkillTraining(21u));
|
|
|
|
var skill = s.GetSkill(21u);
|
|
Assert.NotNull(skill);
|
|
Assert.Equal(2u, skill!.Value.Status);
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySkillRaise_AddsRanksAndSpentXp()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnSkillUpdate(
|
|
skillId: 24u,
|
|
ranks: 12u,
|
|
status: 2u,
|
|
xp: 3456u,
|
|
init: 30u,
|
|
resistance: 0u,
|
|
lastUsed: 0,
|
|
formulaBonus: 80u);
|
|
|
|
Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u));
|
|
|
|
var run = s.GetSkill(24u);
|
|
Assert.NotNull(run);
|
|
Assert.Equal(22u, run!.Value.Ranks);
|
|
Assert.Equal(3956u, run.Value.Xp);
|
|
Assert.Equal(132u, run.Value.CurrentLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyAttributeRaise_AddsRanksAndSpentXp()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u);
|
|
|
|
Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u));
|
|
|
|
var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus);
|
|
Assert.NotNull(focus);
|
|
Assert.Equal(11u, focus!.Value.Ranks);
|
|
Assert.Equal(125u, focus.Value.Xp);
|
|
Assert.Equal(21u, focus.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u);
|
|
|
|
Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u));
|
|
|
|
var health = s.Get(LocalPlayerState.VitalKind.Health);
|
|
Assert.NotNull(health);
|
|
Assert.Equal(6u, health!.Value.Ranks);
|
|
Assert.Equal(125u, health.Value.Xp);
|
|
Assert.Equal(12u, health.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Ints[0x18u] = 3;
|
|
s.OnProperties(props);
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.True(s.DebitIntProperty(0x18u, 2));
|
|
Assert.Equal(1, s.Properties.GetInt(0x18u));
|
|
|
|
Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0
|
|
Assert.Equal(0, s.Properties.GetInt(0x18u));
|
|
Assert.Equal(2, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitIntProperty_Absent_FalseAndNoEvent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.False(s.DebitIntProperty(0x18u, 1));
|
|
Assert.Equal(0, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Int64s[2u] = 500L;
|
|
s.OnProperties(props);
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.True(s.DebitInt64Property(2u, 100L));
|
|
Assert.Equal(400L, s.Properties.GetInt64(2u));
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitInt64Property_ZeroOrAbsent_False()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
Assert.False(s.DebitInt64Property(2u, 100L)); // absent
|
|
|
|
var props = new PropertyBundle();
|
|
props.Int64s[2u] = 0L;
|
|
s.OnProperties(props);
|
|
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
|
|
}
|
|
}
|